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
@@ -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 })
}
},