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:
co-authored by
Claude Opus 4.6
parent
aa405b9a74
commit
bca00cc5bd
@@ -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"
|
||||
>
|
||||
"Glömt lösenord?"
|
||||
</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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user