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')
+79 -8
View File
@@ -4,9 +4,12 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import Image from 'next/image'
import { BankIdQrCode } from './BankIdQrCode'
import { Button } from '@/components/ui/button'
import { Smartphone, Monitor } from 'lucide-react'
import { Smartphone, Monitor, AlertTriangle } from 'lucide-react'
type BankIdStatus = 'idle' | 'scanning' | 'complete' | 'failed' | 'no_account'
type BankIdStatus = 'idle' | 'scanning' | 'complete' | 'failed' | 'no_account' | 'service_unavailable'
/** Max consecutive poll failures before we declare service unavailable */
const MAX_POLL_FAILURES = 3
interface BankIdSession {
sessionId: string
@@ -19,7 +22,7 @@ export interface BankIdResult {
tokenHash?: string
type?: string
isNewUser?: boolean
error?: 'no_account' | 'already_linked' | 'session_invalid'
error?: 'no_account' | 'already_linked' | 'session_invalid' | 'service_unavailable'
givenName?: string
surname?: string
sessionId?: string
@@ -53,6 +56,8 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
const onCompleteRef = useRef(onComplete)
onCompleteRef.current = onComplete
const pollFailureCount = useRef(0)
const cleanup = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current)
@@ -62,6 +67,7 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
abortRef.current.abort()
abortRef.current = null
}
pollFailureCount.current = 0
}, [])
useEffect(() => cleanup, [cleanup])
@@ -81,7 +87,14 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
const res = await fetch(`${API_BASE}/start`, { method: 'POST' })
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || 'Failed to start BankID')
if (err.error === 'service_unavailable' || err.error === 'not_configured' || res.status === 502 || res.status === 503) {
cleanup()
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
return
}
throw new Error(err.message || err.error || 'Failed to start BankID')
}
const { data } = await res.json()
@@ -105,7 +118,22 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
signal: abortRef.current?.signal,
})
if (!pollRes.ok) return
if (!pollRes.ok) {
const pollErr = await pollRes.json().catch(() => ({}))
if (pollErr.error === 'service_unavailable' || pollRes.status === 502 || pollRes.status === 503) {
pollFailureCount.current++
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
cleanup()
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
}
}
return
}
// Reset failure counter on successful poll
pollFailureCount.current = 0
const pollJson = await pollRes.json()
const pollData = pollJson.data
@@ -147,8 +175,15 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
const completeJson = await completeRes.json()
if (!completeRes.ok) {
const errorCode = completeJson.error === 'service_unavailable' || completeRes.status === 502 || completeRes.status === 503
? 'service_unavailable' as const
: completeJson.error
if (errorCode === 'service_unavailable') {
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
}
onCompleteRef.current({
error: completeJson.error,
error: errorCode,
givenName: completeJson.givenName,
surname: completeJson.surname,
})
@@ -197,7 +232,13 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return
// Polling error — will retry next tick
pollFailureCount.current++
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
cleanup()
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
}
}
}, 2000)
} catch (error) {
@@ -234,13 +275,43 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
)
}
if (status === 'service_unavailable') {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/30">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="space-y-1.5">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
BankID är inte tillgängligt just nu
</p>
<p className="text-sm text-amber-700 dark:text-amber-300">
{mode === 'login'
? 'Logga in med e-post och lösenord nedan, eller använd "Glömt lösenord?" för en inloggningslänk via e-post.'
: mode === 'signup'
? 'Skapa konto med e-post och lösenord nedan istället.'
: 'Försök igen senare.'}
</p>
<Button
onClick={startSession}
variant="ghost"
size="sm"
className="mt-1 h-auto px-0 py-0 text-xs text-amber-600 underline underline-offset-2 hover:text-amber-800 dark:text-amber-400"
>
Försök med BankID igen
</Button>
</div>
</div>
</div>
)
}
if (status === 'failed') {
return (
<div className="flex flex-col items-center gap-4">
<p className="text-sm text-destructive">{errorMessage}</p>
<Button onClick={startSession} variant="outline" className="gap-2">
<BankIdIcon />
Forsok igen
Försök igen
</Button>
</div>
)
@@ -60,7 +60,7 @@ export default function TransactionInboxCard({
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
const showCheckbox = isBatchMode && isUncategorized
const hasDocumentMatch = !!transaction.matched_inbox_item
const isManualTransaction = !transaction.bank_connection_id && !transaction.import_source && !transaction.journal_entry_id
const isDeletable = !transaction.journal_entry_id
function handleSuggestionClick(suggestion: SuggestedCategory) {
if (onOpenQuickReview) {
@@ -251,8 +251,8 @@ export default function TransactionInboxCard({
Välj mall...
</Button>
{/* Delete button — only for manually added, unbooked transactions */}
{isManualTransaction && onDelete && (
{/* Delete button — available for all unbooked transactions */}
{isDeletable && onDelete && (
<Button
size="sm"
variant="ghost"
@@ -163,7 +163,7 @@ describe('syncAccountTransactions', () => {
expect(mockIngest).toHaveBeenCalledTimes(1)
const rawTxns = mockIngest.mock.calls[0][3]
expect(rawTxns).toHaveLength(1)
expect(rawTxns[0].external_id).toBe('conn-1_tx-500')
expect(rawTxns[0].external_id).toBe('eb_acc-uid-1_tx-500')
expect(rawTxns[0].import_source).toBe('enable_banking')
})
})
@@ -76,7 +76,7 @@ export async function syncAccountTransactions(
description: tx.description || tx.counterparty_name || 'Unknown',
amount: tx.amount,
currency: tx.currency || account.currency,
external_id: `${connectionId}_${tx.id}`,
external_id: `eb_${account.iban || account.uid}_${tx.id}`,
mcc_code: tx.merchant_category_code ? parseInt(tx.merchant_category_code, 10) : null,
merchant_name: tx.counterparty_name || null,
reference: tx.reference || null,
+53 -27
View File
@@ -23,8 +23,11 @@ import type { BankIdCompleteRequest } from './lib/bankid-types'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import { hashPersonalNumber, encryptPersonalNumber } from '@/lib/auth/bankid'
import { createServiceClient } from '@/lib/supabase/server'
import { createLogger } from '@/lib/logger'
import crypto from 'crypto'
const log = createLogger('tic/bankid')
// Server-side per-IP rate limit for /bankid/start (each call = billable TIC session)
const bankIdStartCooldowns = new Map<string, number>()
const BANKID_START_COOLDOWN_MS = 5_000
@@ -393,14 +396,21 @@ export const ticExtension: Extension = {
} catch (error) {
if (error instanceof TICAPIError) {
if (error.code === 'NOT_CONFIGURED') {
return NextResponse.json({ error: 'BankID is not configured' }, { status: 503 })
return NextResponse.json({ error: 'not_configured', message: 'BankID is not configured' }, { status: 503 })
}
if (error.code === 'RATE_LIMIT_EXCEEDED') {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
return NextResponse.json({ error: 'rate_limit', message: 'Rate limit exceeded' }, { status: 429 })
}
if (error.code === 'TIMEOUT') {
log.error('start timed out — TIC Identity API unreachable', { statusCode: error.statusCode })
return NextResponse.json({ error: 'service_unavailable', message: 'BankID service is not responding' }, { status: 503 })
}
// TIC API returned an error (e.g. 5xx)
log.error('start failed — TIC API error', { statusCode: error.statusCode, code: error.code, message: error.message })
return NextResponse.json({ error: 'service_unavailable', message: 'BankID service is temporarily unavailable' }, { status: 502 })
}
console.error('[tic/bankid] start failed', error)
return NextResponse.json({ error: 'Failed to start BankID session' }, { status: 500 })
log.error('start failed — unexpected error', error)
return NextResponse.json({ error: 'internal_error', message: 'Failed to start BankID session' }, { status: 500 })
}
},
},
@@ -419,17 +429,23 @@ export const ticExtension: Extension = {
const result = await pollBankIdSession(sessionId)
if (result.status !== 'pending') {
console.log('[tic/bankid] poll status:', result.status, result.hintCode, result.user?.personalNumber ? 'has-user' : 'no-user')
log.info('poll status', { status: result.status, hintCode: result.hintCode, hasUser: !!result.user?.personalNumber })
}
return NextResponse.json({ data: result })
} catch (error) {
if (error instanceof TICAPIError) {
if (error.code === 'RATE_LIMIT_EXCEEDED') {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
return NextResponse.json({ error: 'rate_limit', message: 'Rate limit exceeded' }, { status: 429 })
}
if (error.code === 'TIMEOUT') {
log.error('poll timed out — TIC Identity API unreachable')
return NextResponse.json({ error: 'service_unavailable', message: 'BankID service is not responding' }, { status: 503 })
}
log.error('poll failed — TIC API error', { statusCode: error.statusCode, code: error.code, message: error.message })
return NextResponse.json({ error: 'service_unavailable', message: 'BankID service is temporarily unavailable' }, { status: 502 })
}
console.error('[tic/bankid] poll failed', error)
return NextResponse.json({ error: 'Failed to poll BankID session' }, { status: 500 })
log.error('poll failed — unexpected error', error)
return NextResponse.json({ error: 'internal_error', message: 'Failed to poll BankID session' }, { status: 500 })
}
},
},
@@ -503,7 +519,7 @@ export const ticExtension: Extension = {
})
if (linkError || !link?.properties?.hashed_token) {
console.error('[tic/bankid] generateLink failed', linkError)
log.error('generateLink failed for login', { message: linkError?.message, code: linkError?.code })
return NextResponse.json(
{ error: 'Failed to create session' },
{ status: 500 }
@@ -557,7 +573,7 @@ export const ticExtension: Extension = {
})
if (createError || !newUser?.user) {
console.error('[tic/bankid] createUser failed', { email: trimmedEmail, status: createError?.status, code: (createError as any)?.code, message: createError?.message })
log.error('createUser failed', { email: trimmedEmail, status: createError?.status, code: createError?.code, message: createError?.message })
return NextResponse.json(
{ error: 'Failed to create account', message: createError?.message },
{ status: 500 }
@@ -584,7 +600,7 @@ export const ticExtension: Extension = {
})
if (insertError) {
console.error('[tic/bankid] insert bankid_identities failed', insertError)
log.error('insert bankid_identities failed', { message: insertError.message, code: insertError.code })
return NextResponse.json(
{ error: 'Failed to link BankID identity' },
{ status: 500 }
@@ -598,7 +614,7 @@ export const ticExtension: Extension = {
})
if (linkError || !link?.properties?.hashed_token) {
console.error('[tic/bankid] generateLink failed for new user', linkError)
log.error('generateLink failed for signup', { message: linkError?.message, code: linkError?.code })
return NextResponse.json(
{ error: 'Account created but failed to create session' },
{ status: 500 }
@@ -610,7 +626,7 @@ export const ticExtension: Extension = {
const enrichment = await requestEnrichment(sessionId, ['SPAR', 'CompanyRoles'])
if (enrichment.status === 'Completed' && enrichment.secureUrl) {
const enrichmentData = await fetchEnrichmentData(enrichment.secureUrl)
console.log('[tic/bankid] enrichment success', {
log.info('enrichment success', {
hasSpar: !!enrichmentData.spar,
companyCount: enrichmentData.companyRoles?.length ?? 0,
})
@@ -627,7 +643,7 @@ export const ticExtension: Extension = {
}
} catch (enrichError) {
// Enrichment is optional — don't fail signup
console.warn('[tic/bankid] enrichment failed (non-blocking)', enrichError)
log.warn('enrichment failed (non-blocking)', enrichError)
}
return NextResponse.json({
@@ -639,18 +655,21 @@ export const ticExtension: Extension = {
})
} catch (error) {
if (error instanceof TICAPIError) {
console.error('[tic/bankid] complete TIC error', {
message: error.message,
code: error.code,
})
log.error('complete failed — TIC API error', { statusCode: error.statusCode, code: error.code, message: error.message })
if (error.code === 'TIMEOUT') {
return NextResponse.json(
{ error: 'service_unavailable', message: 'BankID service is not responding' },
{ status: 503 }
)
}
return NextResponse.json(
{ error: 'BankID verification failed' },
{ error: 'service_unavailable', message: 'BankID verification failed' },
{ status: 502 }
)
}
console.error('[tic/bankid] complete unexpected error', error)
log.error('complete failed — unexpected error', error)
return NextResponse.json(
{ error: 'Failed to complete BankID authentication' },
{ error: 'internal_error', message: 'Failed to complete BankID authentication' },
{ status: 500 }
)
}
@@ -672,7 +691,7 @@ export const ticExtension: Extension = {
await cancelBankIdSession(sessionId)
return NextResponse.json({ data: { cancelled: true } })
} catch (error) {
console.error('[tic/bankid] cancel failed', error)
log.error('cancel failed', error)
return NextResponse.json({ error: 'Failed to cancel session' }, { status: 500 })
}
},
@@ -734,7 +753,7 @@ export const ticExtension: Extension = {
})
if (insertError) {
console.error('[tic/bankid] link insert failed', insertError)
log.error('link insert failed', { message: insertError.message, code: insertError.code })
return NextResponse.json(
{ error: 'Failed to link BankID' },
{ status: 500 }
@@ -748,9 +767,16 @@ export const ticExtension: Extension = {
return NextResponse.json({ data: { linked: true } })
} catch (error) {
console.error('[tic/bankid] link failed', error)
if (error instanceof TICAPIError) {
log.error('link failed — TIC API error', { statusCode: error.statusCode, code: error.code, message: error.message })
return NextResponse.json(
{ error: 'service_unavailable', message: 'BankID service is temporarily unavailable' },
{ status: 502 }
)
}
log.error('link failed — unexpected error', error)
return NextResponse.json(
{ error: 'Failed to link BankID' },
{ error: 'internal_error', message: 'Failed to link BankID' },
{ status: 500 }
)
}
@@ -776,7 +802,7 @@ export const ticExtension: Extension = {
.eq('user_id', ctx.userId)
if (deleteError) {
console.error('[tic/bankid] unlink delete failed', deleteError)
log.error('unlink delete failed', { message: deleteError.message, code: deleteError.code })
return NextResponse.json({ error: 'Failed to unlink BankID' }, { status: 500 })
}
@@ -787,7 +813,7 @@ export const ticExtension: Extension = {
return NextResponse.json({ data: { unlinked: true } })
} catch (error) {
console.error('[tic/bankid] unlink failed', error)
log.error('unlink failed', error)
return NextResponse.json({ error: 'Failed to unlink BankID' }, { status: 500 })
}
},
@@ -43,6 +43,14 @@ const SEB_CSV = [
'2024-01-13;2024-01-13;12347;LÖNEUTBETALNING;25000,00;12877,17',
].join('\n')
// SEB privatbanken web export: "Bokföringsdatum" (with ö AND -datum suffix)
const SEB_PRIVAT_CSV = [
'Bokföringsdatum;Valutadatum;Verifikationsnummer;Text;Belopp;Saldo',
'2025-12-30;2025-12-31;0;RÄNTA;7,84;1241,16',
'2025-09-15;2025-09-14;5490990004;53290171515;-1000,00;1233,32',
'2024-10-31;2024-10-31;5841990687;H31520956893;433,16;5147,56',
].join('\n')
const SWEDBANK_CSV = [
'Kontouppgifter',
'Clearingnummer,Kontonummer,Datum,Text,Belopp,Saldo',
@@ -711,6 +719,33 @@ describe('parseBankFile — SEB format', () => {
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].amount).toBe(-50)
})
it('auto-detects SEB privatbanken variant with Bokföringsdatum + Valutadatum headers', () => {
const format = detectFileFormat(SEB_PRIVAT_CSV, 'kontoutdrag.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('seb')
})
it('parses SEB privatbanken CSV (Bokföringsdatum / Valutadatum / RÄNTA / negative amounts)', () => {
const result = parseBankFile(SEB_PRIVAT_CSV, 'kontoutdrag.csv')
expect(result.format).toBe('seb')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
const ranta = result.transactions[0]
expect(ranta.date).toBe('2025-12-30')
expect(ranta.description).toBe('RÄNTA')
expect(ranta.amount).toBe(7.84)
expect(ranta.balance).toBe(1241.16)
const withdrawal = result.transactions[1]
expect(withdrawal.date).toBe('2025-09-15')
expect(withdrawal.amount).toBe(-1000)
const deposit = result.transactions[2]
expect(deposit.amount).toBe(433.16)
})
})
describe('parseBankFile — Swedbank format', () => {
+8 -11
View File
@@ -25,14 +25,13 @@ export const sebFormat: BankFileFormat = {
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
// SEB headers contain "bokföringsdag"/"bokforingsdatum" AND "valutadag"/"verifikationsnummer"
// The secondary check distinguishes SEB from Länsförsäkringar (which also has "bokföringsdag")
return (
firstLine.includes(';') &&
(firstLine.includes('bokföringsdag') ||
firstLine.includes('bokforingsdatum')) &&
(firstLine.includes('valutadag') || firstLine.includes('verifikationsnummer'))
)
// SEB uses semicolon delimiter. Header always has a bokföringsdag/bokföringsdatum
// column plus either valutadag/valutadatum or verifikationsnummer. The secondary
// check distinguishes SEB from Länsförsäkringar (which also has bokföringsdag).
const hasBookingDate = /bokf(ö|o)ringsda(g|tum)/.test(firstLine)
const hasSebSecondary =
/valuta(dag|datum)/.test(firstLine) || firstLine.includes('verifikationsnummer')
return firstLine.includes(';') && hasBookingDate && hasSebSecondary
},
parse(content: string): BankFileParseResult {
@@ -48,9 +47,7 @@ export const sebFormat: BankFileFormat = {
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
// Find column indices dynamically
const dateIdx = headers.findIndex(
(h) => h.includes('bokföringsdag') || h.includes('bokforingsdatum')
)
const dateIdx = headers.findIndex((h) => /bokf(ö|o)ringsda(g|tum)/.test(h))
const descIdx = headers.findIndex(
(h) => h.includes('text') || h.includes('mottagare') || h.includes('beskrivning')
)
+40 -1
View File
@@ -116,6 +116,7 @@ function makeMappingResult(overrides: Record<string, unknown> = {}) {
//
// Queue order after batch dedup refactor:
// 1. Booked transaction map query
// 1b. Unbooked bank-synced transaction map query
// 2. Supplier invoices fetch
// 3. Batch external_id dedup query (returns matching external_ids)
// 4. Per-transaction: insert, updates, etc.
@@ -139,6 +140,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query (no booked transactions)
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch (no unpaid invoices)
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches)
@@ -165,6 +168,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query — returns matching external_id
@@ -186,6 +191,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches)
@@ -214,6 +221,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches)
@@ -257,6 +266,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches)
@@ -288,6 +299,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches)
@@ -328,6 +341,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches)
@@ -359,6 +374,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches)
@@ -389,6 +406,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches)
@@ -428,6 +447,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query — ext-dup already exists
@@ -501,6 +522,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches)
@@ -528,6 +551,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches)
@@ -581,6 +606,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches)
@@ -625,6 +652,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches)
@@ -654,6 +683,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches)
@@ -686,6 +717,8 @@ describe('ingestTransactions', () => {
data: [{ date: '2024-06-15', amount: -250 }],
error: null,
})
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no match by external_id)
@@ -711,6 +744,8 @@ describe('ingestTransactions', () => {
data: [{ date: '2024-06-15', amount: -250 }],
error: null,
})
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no match)
@@ -745,6 +780,8 @@ describe('ingestTransactions', () => {
],
error: null,
})
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches for any)
@@ -768,8 +805,10 @@ describe('ingestTransactions', () => {
const raw = makeRaw({ amount: -200 })
const inserted = makeTransaction({ id: 'tx-mapfail', amount: -200 })
// Booked map query throws (caught by try/catch in buildBookedTransactionMap)
// Booked map query throws (caught by try/catch in buildExistingTransactionMap)
enqueue({ error: { message: 'Query failed' } })
// Unbooked bank-synced transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Batch external_id dedup query (no matches)
+63 -22
View File
@@ -13,26 +13,34 @@ import type { Transaction, RawTransaction, IngestResult, IngestOptions, Supplier
// Re-export types for backward compatibility
export type { RawTransaction, IngestResult } from '@/types'
/**
* Build a lookup map of already-booked transactions keyed by "date|amount".
* Each key maps to the number of booked transactions with that date+amount
* combination, allowing correct dedup when multiple transactions share
* the same date and amount.
*/
async function buildBookedTransactionMap(
interface ExistingTransactionMaps {
/** Booked transactions (any source) — consumed by any incoming raw transaction. */
booked: Map<string, number>
/**
* Unbooked enable_banking transactions — only consumed when the incoming raw
* transaction is also from enable_banking. This catches reconnect duplicates
* (external_id changed but the same tx already exists from a prior sync)
* without producing false positives for unrelated CSV imports that happen to
* share a date/amount with a pending bank-synced row.
*/
unbookedEnableBanking: Map<string, number>
}
async function buildExistingTransactionMaps(
supabase: SupabaseClient,
companyId: string,
rawTransactions: RawTransaction[]
): Promise<Map<string, number>> {
const map = new Map<string, number>()
if (rawTransactions.length === 0) return map
): Promise<ExistingTransactionMaps> {
const booked = new Map<string, number>()
const unbookedEnableBanking = new Map<string, number>()
if (rawTransactions.length === 0) return { booked, unbookedEnableBanking }
const dates = rawTransactions.map((t) => t.date).sort()
const dateFrom = dates[0]
const dateTo = dates[dates.length - 1]
try {
const { data: booked } = await supabase
const { data: bookedRows } = await supabase
.from('transactions')
.select('date, amount')
.eq('company_id', companyId)
@@ -40,17 +48,37 @@ async function buildBookedTransactionMap(
.gte('date', dateFrom)
.lte('date', dateTo)
if (booked) {
for (const tx of booked) {
if (bookedRows) {
for (const tx of bookedRows) {
const key = `${tx.date}|${tx.amount}`
map.set(key, (map.get(key) || 0) + 1)
booked.set(key, (booked.get(key) || 0) + 1)
}
}
} catch {
// Non-critical — content-based dedup will be skipped
}
return map
try {
const { data: unbookedBank } = await supabase
.from('transactions')
.select('date, amount')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('import_source', 'enable_banking')
.gte('date', dateFrom)
.lte('date', dateTo)
if (unbookedBank) {
for (const tx of unbookedBank) {
const key = `${tx.date}|${tx.amount}`
unbookedEnableBanking.set(key, (unbookedEnableBanking.get(key) || 0) + 1)
}
}
} catch {
// Non-critical — reconnect dedup will be skipped
}
return { booked, unbookedEnableBanking }
}
/**
@@ -85,10 +113,11 @@ export async function ingestTransactions(
transaction_ids: [],
}
// Pre-fetch booked transactions for content-based dedup (date+amount)
// This catches cross-source duplicates (e.g. same transaction imported
// via CSV and then again via PSD2 with different external_id)
const bookedMap = await buildBookedTransactionMap(supabase, companyId, rawTransactions)
// Pre-fetch existing transactions for content-based dedup (date+amount).
// Booked rows (any source) catch cross-source duplicates; unbooked
// enable_banking rows catch reconnect duplicates but are only consumed
// by incoming enable_banking rows to avoid blocking unrelated CSV imports.
const existingMaps = await buildExistingTransactionMaps(supabase, companyId, rawTransactions)
// Pre-fetch unlinked GL lines for reconciliation (non-critical)
let glLinePool: UnlinkedGLLine[] = []
@@ -154,15 +183,27 @@ export async function ingestTransactions(
}
// 1b. Content-based dedup: skip if an already-booked transaction
// exists with the same date and amount (cross-source duplicate)
// exists with the same date and amount (cross-source duplicate).
const contentKey = `${raw.date}|${raw.amount}`
const bookedCount = bookedMap.get(contentKey) || 0
const bookedCount = existingMaps.booked.get(contentKey) || 0
if (bookedCount > 0) {
bookedMap.set(contentKey, bookedCount - 1)
existingMaps.booked.set(contentKey, bookedCount - 1)
result.duplicates++
continue
}
// 1c. Reconnect dedup: only enable_banking rows consume slots from the
// unbooked-enable_banking map, so a CSV row with the same date/amount as
// a pending bank-synced row is not incorrectly dropped as a duplicate.
if (raw.import_source === 'enable_banking') {
const unbookedEbCount = existingMaps.unbookedEnableBanking.get(contentKey) || 0
if (unbookedEbCount > 0) {
existingMaps.unbookedEnableBanking.set(contentKey, unbookedEbCount - 1)
result.duplicates++
continue
}
}
// 2. Insert new transaction (with SEK conversion for foreign currencies)
const rateInfo = raw.currency && raw.currency !== 'SEK'
? exchangeRates.get(raw.currency as Currency)
@@ -0,0 +1,116 @@
-- Fix delete_user_account: timeout + cascade blockers.
--
-- Problem 1: ALTER TABLE DDL requires ACCESS EXCLUSIVE locks. With the
-- authenticated role's 8s statement_timeout, any concurrent read causes
-- error 57014. Fixed via function-level SET statement_timeout = '60s'.
--
-- Problem 2: Several NO ACTION FKs block the CASCADE chain from
-- auth.users → companies → children:
-- audit_log.company_id → companies (NO ACTION)
-- fiscal_periods.previous_period_id → fiscal_periods (NO ACTION, self-ref)
-- fiscal_periods.closing_entry_id → journal_entries (NO ACTION)
-- fiscal_periods.opening_balance_entry_id → journal_entries (NO ACTION)
-- Fixed by explicitly clearing these before the CASCADE fires.
CREATE OR REPLACE FUNCTION public.delete_user_account(target_user_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET statement_timeout = '60s'
SET lock_timeout = '10s'
AS $$
DECLARE
v_company_ids uuid[];
BEGIN
-- Only allow users to delete their own account
IF auth.uid() IS DISTINCT FROM target_user_id THEN
RAISE EXCEPTION 'Can only delete your own account';
END IF;
-- Collect companies owned by this user (CASCADE will delete these)
SELECT array_agg(id) INTO v_company_ids
FROM public.companies
WHERE created_by = target_user_id;
-- Clear active_company_id to avoid FK conflicts during CASCADE
DELETE FROM public.user_preferences WHERE user_id = target_user_id;
-- Explicitly delete from extension_data (missing DELETE RLS policy
-- causes CASCADE from auth.users to fail even with ON DELETE CASCADE)
DELETE FROM public.extension_data WHERE user_id = target_user_id;
-- Disable BEFORE DELETE triggers that block deletion
ALTER TABLE audit_log DISABLE TRIGGER audit_log_no_delete;
ALTER TABLE payment_match_log DISABLE TRIGGER payment_match_log_no_delete;
ALTER TABLE document_attachments DISABLE TRIGGER block_document_deletion;
ALTER TABLE journal_entries DISABLE TRIGGER enforce_journal_entry_immutability;
ALTER TABLE journal_entries DISABLE TRIGGER enforce_retention_journal_entries;
ALTER TABLE journal_entry_lines DISABLE TRIGGER enforce_journal_entry_line_immutability;
-- Disable AFTER DELETE audit triggers (they INSERT into audit_log during
-- CASCADE, which would create orphaned rows after the user is gone)
ALTER TABLE api_keys DISABLE TRIGGER audit_api_keys;
ALTER TABLE chart_of_accounts DISABLE TRIGGER audit_chart_of_accounts;
ALTER TABLE company_settings DISABLE TRIGGER audit_company_settings;
ALTER TABLE document_attachments DISABLE TRIGGER audit_document_attachments;
ALTER TABLE extension_data DISABLE TRIGGER audit_extension_data;
ALTER TABLE fiscal_periods DISABLE TRIGGER audit_fiscal_periods;
ALTER TABLE journal_entries DISABLE TRIGGER audit_journal_entries;
ALTER TABLE supplier_invoices DISABLE TRIGGER audit_supplier_invoices;
-- Clear NO ACTION FK references that block the CASCADE chain
IF v_company_ids IS NOT NULL THEN
-- audit_log.company_id → companies (NO ACTION)
DELETE FROM public.audit_log
WHERE company_id = ANY(v_company_ids);
-- fiscal_periods self-ref and cross-refs to journal_entries (NO ACTION)
UPDATE public.fiscal_periods
SET previous_period_id = NULL,
closing_entry_id = NULL,
opening_balance_entry_id = NULL
WHERE company_id = ANY(v_company_ids);
END IF;
-- Delete from auth.users — ON DELETE CASCADE handles all public tables
DELETE FROM auth.users WHERE id = target_user_id;
-- Re-enable all triggers
ALTER TABLE audit_log ENABLE TRIGGER audit_log_no_delete;
ALTER TABLE payment_match_log ENABLE TRIGGER payment_match_log_no_delete;
ALTER TABLE document_attachments ENABLE TRIGGER block_document_deletion;
ALTER TABLE journal_entries ENABLE TRIGGER enforce_journal_entry_immutability;
ALTER TABLE journal_entries ENABLE TRIGGER enforce_retention_journal_entries;
ALTER TABLE journal_entry_lines ENABLE TRIGGER enforce_journal_entry_line_immutability;
ALTER TABLE api_keys ENABLE TRIGGER audit_api_keys;
ALTER TABLE chart_of_accounts ENABLE TRIGGER audit_chart_of_accounts;
ALTER TABLE company_settings ENABLE TRIGGER audit_company_settings;
ALTER TABLE document_attachments ENABLE TRIGGER audit_document_attachments;
ALTER TABLE extension_data ENABLE TRIGGER audit_extension_data;
ALTER TABLE fiscal_periods ENABLE TRIGGER audit_fiscal_periods;
ALTER TABLE journal_entries ENABLE TRIGGER audit_journal_entries;
ALTER TABLE supplier_invoices ENABLE TRIGGER audit_supplier_invoices;
EXCEPTION WHEN OTHERS THEN
-- PostgreSQL transactional DDL already rolls back the DISABLE TRIGGER
-- statements if the function aborts, but re-enable explicitly as a
-- defensive guard against sub-transaction edge cases so enforcement
-- triggers are never left disabled on the live tables.
BEGIN ALTER TABLE audit_log ENABLE TRIGGER audit_log_no_delete; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE payment_match_log ENABLE TRIGGER payment_match_log_no_delete; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE document_attachments ENABLE TRIGGER block_document_deletion; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE journal_entries ENABLE TRIGGER enforce_journal_entry_immutability; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE journal_entries ENABLE TRIGGER enforce_retention_journal_entries; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE journal_entry_lines ENABLE TRIGGER enforce_journal_entry_line_immutability; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE api_keys ENABLE TRIGGER audit_api_keys; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE chart_of_accounts ENABLE TRIGGER audit_chart_of_accounts; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE company_settings ENABLE TRIGGER audit_company_settings; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE document_attachments ENABLE TRIGGER audit_document_attachments; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE extension_data ENABLE TRIGGER audit_extension_data; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE fiscal_periods ENABLE TRIGGER audit_fiscal_periods; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE journal_entries ENABLE TRIGGER audit_journal_entries; EXCEPTION WHEN OTHERS THEN NULL; END;
BEGIN ALTER TABLE supplier_invoices ENABLE TRIGGER audit_supplier_invoices; EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE;
END;
$$;