fix: BankID fallback, bank import fixes, account deletion (#216)

* fix: BankID graceful fallback, bank import fixes, and account deletion

- BankID: surface service_unavailable state in login/register with password
  fallback messaging; structured error codes in tic extension; poll failure
  counter in BankIdAuth avoids infinite retry when TIC API is down.
- Transactions: allow deleting unbooked bank-synced and imported transactions
  (only posted entries remain protected); detect reconnect duplicates by also
  checking unbooked bank-synced rows in content-based dedup.
- Enable Banking: key external_id by account iban/uid instead of connection id
  so reconnects don't create duplicates.
- Bank import: detect SEB privatbanken CSV variant (Bokföringsdatum /
  Valutadatum headers) via regex.
- Banking settings: replace full-screen sync loader with toast notifications.
- delete_user_account: raise statement_timeout, pre-clear NO ACTION FK
  references, and disable audit/immutability triggers during CASCADE.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — ingest dedup scoping and migration EXCEPTION handler

- ingest: split buildExistingTransactionMap into two maps. Booked rows
  (any source) remain consumed by any incoming raw transaction, but
  unbooked enable_banking slots are only consumed when the incoming raw
  transaction is also enable_banking. This preserves reconnect dedup
  while preventing false positives where a pending bank-synced row
  silently blocks a legitimately separate CSV row with the same
  date/amount.
- delete_user_account: add EXCEPTION WHEN OTHERS handler that re-enables
  every legally required enforcement trigger before re-raising. Postgres
  transactional DDL already rolls back on abort, but the explicit guard
  makes the intent unambiguous and covers sub-transaction edge cases so
  enforcement triggers are never left disabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-11 13:02:50 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent aa405b9a74
commit bca00cc5bd
15 changed files with 465 additions and 141 deletions
+26 -1
View File
@@ -42,16 +42,23 @@ export default function LoginPage() {
return () => clearInterval(interval)
}, [resetCooldownUntil])
const [bankIdUnavailable, setBankIdUnavailable] = useState(false)
const handleBankIdComplete = async (result: BankIdResult) => {
if (result.error === 'no_account') {
setBankIdNoAccount({ givenName: result.givenName, surname: result.surname })
return
}
if (result.error === 'service_unavailable') {
setBankIdUnavailable(true)
return
}
if (result.error) {
toast({
title: 'Inloggning misslyckades',
description: 'Kunde inte slutfora BankID-inloggningen.',
description: 'Kunde inte slutföra BankID-inloggningen.',
variant: 'destructive',
})
return
@@ -376,6 +383,24 @@ export default function LoginPage() {
</div>
</>
)}
{bankIdUnavailable && (
<div className="mb-5 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/30">
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
Har du inget lösenord?
</p>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
Om du skapade ditt konto med BankID kan du använda{' '}
<button
type="button"
onClick={() => setShowResetPassword(true)}
className="font-medium underline underline-offset-2"
>
&quot;Glömt lösenord?&quot;
</button>{' '}
för att få en inloggningslänk via e-post.
</p>
</div>
)}
<form onSubmit={handlePasswordLogin} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-postadress</Label>
+15
View File
@@ -60,7 +60,14 @@ function RegisterPageContent() {
.catch(() => {})
}, [searchParams])
const [bankIdUnavailable, setBankIdUnavailable] = useState(false)
const handleBankIdComplete = (result: BankIdResult) => {
if (result.error === 'service_unavailable') {
setBankIdUnavailable(true)
return
}
if (result.error) {
toast({
title: 'BankID misslyckades',
@@ -350,6 +357,14 @@ function RegisterPageContent() {
</>
)}
{bankIdUnavailable && !bankIdUser && (
<div className="mb-5 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/30">
<p className="text-sm text-blue-700 dark:text-blue-300">
Skapa konto med e-post och lösenord nedan istället. Du kan koppla BankID i inställningar senare.
</p>
</div>
)}
{bankIdUser ? (
<form onSubmit={handleBankIdSignup} className="space-y-5">
<div className="rounded-lg border bg-muted/30 p-3">
+11 -42
View File
@@ -6,7 +6,7 @@ import Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { AlertTriangle, CheckCircle2, CreditCard, ExternalLink, Loader2 } from 'lucide-react'
import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
@@ -17,9 +17,6 @@ export default function BankingSettingsPage() {
const router = useRouter()
const { toast } = useToast()
const [bankConnectionError, setBankConnectionError] = useState<string | null>(null)
const [isSyncing, setIsSyncing] = useState(false)
const [syncResult, setSyncResult] = useState<{ imported: number } | null>(null)
const successTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
const syncInitiatedRef = useRef(false)
const abortControllerRef = useRef<AbortController | null>(null)
const unmountedRef = useRef(false)
@@ -28,7 +25,6 @@ export default function BankingSettingsPage() {
useEffect(() => {
return () => {
unmountedRef.current = true
if (successTimerRef.current) clearTimeout(successTimerRef.current)
if (abortControllerRef.current) abortControllerRef.current.abort()
}
}, [])
@@ -43,7 +39,10 @@ export default function BankingSettingsPage() {
router.replace('/settings/banking')
if (connectionId) {
setIsSyncing(true)
toast({
title: 'Synkroniserar transaktioner...',
description: 'Hämtar transaktioner från din bank i bakgrunden.',
})
const controller = new AbortController()
abortControllerRef.current = controller
const syncTimeout = setTimeout(() => controller.abort(), 120_000)
@@ -59,11 +58,12 @@ export default function BankingSettingsPage() {
clearTimeout(syncTimeout)
const data = await res.json()
if (res.ok) {
setSyncResult({ imported: data.imported ?? 0 })
successTimerRef.current = setTimeout(() => {
setIsSyncing(false)
setSyncResult(null)
}, 3000)
if (!unmountedRef.current) {
toast({
title: 'Bank ansluten!',
description: `${data.imported ?? 0} transaktioner importerade`,
})
}
} else {
throw new Error(data.error || 'Sync failed')
}
@@ -82,7 +82,6 @@ export default function BankingSettingsPage() {
variant: 'destructive',
})
}
setIsSyncing(false)
}
})()
} else {
@@ -105,36 +104,6 @@ export default function BankingSettingsPage() {
}
}, [searchParams, router, toast])
if (isSyncing) {
return (
<div className="space-y-6">
<Card className="mx-auto max-w-md">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
{syncResult ? (
<>
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<CheckCircle2 className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<p className="font-medium text-lg">Bank ansluten!</p>
<p className="mt-1 text-sm text-muted-foreground">
{syncResult.imported} transaktioner importerade
</p>
</>
) : (
<>
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground mb-4" />
<p className="font-medium">Hämtar transaktioner från din bank...</p>
<p className="mt-1 text-sm text-muted-foreground">
Detta kan ta upp till en minut
</p>
</>
)}
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{bankConnectionError && (
@@ -60,28 +60,30 @@ describe('DELETE /api/transactions/[id]', () => {
expect(body.error).toContain('booked')
})
it('returns 409 when transaction is bank-synced', async () => {
it('allows deleting unbooked bank-synced transactions', async () => {
const tx = makeTransaction({ bank_connection_id: 'bc-1', journal_entry_id: null, import_source: null })
enqueue({ data: tx, error: null })
enqueue({ data: tx, error: null }) // fetch
enqueue({ data: null, error: null }) // delete
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(409)
expect(body.error).toContain('bank-synced')
expect(status).toBe(200)
expect(body).toEqual({ success: true })
})
it('returns 409 when transaction was imported', async () => {
it('allows deleting unbooked imported transactions', async () => {
const tx = makeTransaction({ import_source: 'csv_nordea', journal_entry_id: null, bank_connection_id: null })
enqueue({ data: tx, error: null })
enqueue({ data: tx, error: null }) // fetch
enqueue({ data: null, error: null }) // delete
const request = new Request('http://localhost/api/transactions/tx-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(409)
expect(body.error).toContain('imported')
expect(status).toBe(200)
expect(body).toEqual({ success: true })
})
it('deletes a manually added unbooked transaction', async () => {
+2 -14
View File
@@ -19,7 +19,7 @@ export async function DELETE(
// Fetch the transaction with ownership check
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('id, journal_entry_id, bank_connection_id, import_source')
.select('id, journal_entry_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
@@ -28,25 +28,13 @@ export async function DELETE(
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// Guard: only manually added, unbooked transactions can be deleted
// Guard: only unbooked transactions can be deleted
if (transaction.journal_entry_id) {
return NextResponse.json(
{ error: 'Cannot delete a booked transaction. Use reversal (storno) instead.' },
{ status: 409 }
)
}
if (transaction.bank_connection_id) {
return NextResponse.json(
{ error: 'Cannot delete a bank-synced transaction' },
{ status: 409 }
)
}
if (transaction.import_source) {
return NextResponse.json(
{ error: 'Cannot delete an imported transaction' },
{ status: 409 }
)
}
const { error: deleteError } = await supabase
.from('transactions')