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