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