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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
$$;
|
||||
Reference in New Issue
Block a user