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
@@ -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 })
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user