fix(reconciliation): exclude ignored transactions from the bank total and bridge whitespace-drifted duplicate descriptions (#1705)

Bank reconciliation counted ignored transactions in bank_transaction_total
while excluding them from the unmatched count, so after the sanctioned
duplicate cleanup (ignore one twin) the differens showed the ignored sum
forever and is_reconciled was unreachable: observed live as a permanent
116 367 kr differens on a fully booked enskild firma (78 867 kr ignored
reconnect duplicates + 37 500 kr genuinely unbooked). The ignore toast
already promised 'försvinner från avstämningen'; now the engine keeps
that promise. Ignored rows are surfaced separately (count + sum) in the
status object, the UI card, and the v1 API, mirroring the IB pattern.

The duplicates themselves came from a PSD2 reconnect: the new connection
re-rendered identical transactions with drifted whitespace (CRLF vs
space, and a DROPPED space), so the prefix-containment content bridge
missed every twin. descriptionsBridge now strips all whitespace before
comparing: char-filtering preserves existing prefix relations, and the
compare stays confined to a (date, öre) bucket.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-19 20:19:26 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 6b9338f17e
commit 506d030bb1
10 changed files with 195 additions and 13 deletions
@@ -18,8 +18,11 @@ import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliatio
// total_unmatched_amount, …) that the endpoint never returned; any client coded
// against it read undefined for every field except difference.
const StatusResponse = z.object({
/** Sum of bank-feed transactions in the window (the bank side). */
/** Sum of bank-feed transactions in the window (the bank side), excluding ignored rows. */
bank_transaction_total: z.number(),
/** Sum of ignored bank transactions in the window. Informational: not part of bank_transaction_total or difference. */
ignored_transaction_total: z.number(),
ignored_transaction_count: z.number().int(),
/** Full ledger balance on the account incl. opening balance: matches the balance sheet. */
gl_1930_balance: z.number(),
/** Ledger movement excluding opening balance: what `difference` compares against. */
@@ -50,11 +53,14 @@ registerEndpoint({
'A non-zero difference is normal between sync runs (uncleared cheques, in-flight transfers). Investigate only if it persists across reconciliations.',
'difference compares against gl_1930_period_movement (movement excl. opening balance), NOT gl_1930_balance. Do not display gl_1930_balance next to difference.',
'is_reconciled means |difference| < 0.01 for the window, an aggregate check, not a per-transaction guarantee.',
'Ignored transactions are excluded from bank_transaction_total and difference (they never get a ledger counterpart); their count and sum are reported separately.',
],
example: {
response: {
data: {
bank_transaction_total: 48150,
ignored_transaction_total: 0,
ignored_transaction_count: 0,
gl_1930_balance: 98150,
gl_1930_period_movement: 48150,
gl_1930_opening_balance: 50000,