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
parent 6b9338f17e
commit 506d030bb1
10 changed files with 195 additions and 13 deletions
+1
View File
@@ -1082,4 +1082,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-18] Migration-reset eligibility treats every persisted Skatteverket VAT `submission_*` workflow row as authority interaction evidence, not only successful audit-log rows: historical direct locks stored the signing state without auditing it, and Accounted cannot observe whether a user completed BankID signing outside the app. Unsigned drafts must be removed through the product; locked or uncertain state fails closed and is escalated.
[2026-08-18] Migration-reset eligibility also blocks AGI `pending_signature` and `agi_submission_*` state plus every ROT/RUT payout request: both flows hand work to Skatteverket for external upload or BankID signing before Accounted can observe the filing outcome, so a missing receipt or locally generated/cancelled status cannot prove that the data is disposable.
[2026-08-19] Keep reversal allocation metadata limited to failures before any reversal header exists: later cleanup preserves a cancelled header with the allocated voucher number, so documenting it as an unused voucher gap would be false.
[2026-08-19] Bank reconciliation: ignored transactions are excluded from bank_transaction_total/difference (surfaced as separate count+sum) rather than keeping the old "what the bank moved" semantics: the ignore flag's dominant real-world use is feed duplicates (which never moved money and never get a ledger leg), so including them made is_reconciled unreachable after a correct dupe cleanup (observed: permanent 78 867 kr differens on a fully booked EF account). descriptionsBridge now strips ALL whitespace before the prefix compare (collapse-only still misses a dropped space); safe because char-filtering preserves prefix relations and the compare stays inside a (date,ore) bucket.
[2026-08-19] Inline rättelse bank guard anchors to the linked bank amount, per account, not to the pre-state and not to the 19xx group net: a non-zero change on a 19xx/cash-ledger account is allowed iff the post-state net on that account equals the signed sum of the linked transactions resolved to it (once per transaction, split links by allocated_amount; NULL cash_account_id resolves to the primary cash account, then 1930). Per account rather than group so a wrong-bank-account booking (1930 vs 1940) stays a storno job: a group check would let the net drift between accounts and break per-account bank reconciliation. When no anchor resolves the old strict refusal stands. Reskontra sides (15xx/24xx) stay strictly net-preserving because their anchor is the payment row, not a bank amount.
@@ -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,
@@ -113,7 +113,11 @@ const QUICK_BOOK_TEMPLATES: {
// ============================================================
interface ReconciliationStatus {
/** Bank-feed total EXCLUDING ignored rows: the reconciling bank side. */
bank_transaction_total: number
/** Sum of ignored rows in the window; informational, not in the difference. */
ignored_transaction_total: number
ignored_transaction_count: number
/**
* @deprecated Kept on the server response for back-compat. The UI no longer
* reads it: `gl_1930_period_movement` is required.
@@ -1061,6 +1065,14 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
{formatCurrency(status.difference)}
</span>
</div>
{status.ignored_transaction_count > 0 && (
<p className="pt-2 text-xs text-muted-foreground">
{t('recon_ignored_note', {
count: status.ignored_transaction_count,
amount: formatCurrency(status.ignored_transaction_total, accountCurrency),
})}
</p>
)}
{status.gl_1930_opening_balance !== 0 && (
<p className="pt-2 text-xs text-muted-foreground">
Ingående balans (IB) <AccountNumber number={accountNumber} />:{' '}
@@ -1336,6 +1336,86 @@ describe('getReconciliationStatus', () => {
expect(status.gl_1930_period_movement).toBe(200)
})
it('excludes ignored transactions from the bank total and difference, surfacing them separately', async () => {
// The 2026-08-18 duplicate-cleanup shape: a PSD2 reconnect re-imported
// history, the user booked one twin and IGNORED the other. The ignored
// duplicate never gets a ledger counterpart, so counting it in the bank
// total manufactured a permanent difference no amount of booking could
// clear (observed live: a 78 867 kr differens over a fully booked account).
const { supabase, enqueue } = createQueueMockSupabase()
// 1) transactions: booked original + its ignored duplicate + one real
// unbooked deposit.
enqueue({
data: [
{ amount: 8730, journal_entry_id: 'je-1', reconciliation_method: 'manual', is_ignored: false },
{ amount: 8730, journal_entry_id: null, reconciliation_method: null, is_ignored: true },
{ amount: 500, journal_entry_id: null, reconciliation_method: null, is_ignored: false },
],
})
// 2) GL: only the booked original is on 1930.
enqueue({ data: [{ id: 'je-1', status: 'posted', source_type: 'bank_import' }] })
enqueue({ data: [{ debit_amount: 8730, credit_amount: 0, journal_entry_id: 'je-1' }] })
// 3) RPC: no unlinked lines
enqueue({ data: [] })
const status = await getReconciliationStatus(supabase as never, 'company-1')
expect(status.bank_transaction_total).toBe(9230) // 8730 + 500, NOT the ignored twin
expect(status.ignored_transaction_count).toBe(1)
expect(status.ignored_transaction_total).toBe(8730)
expect(status.gl_1930_period_movement).toBe(8730)
expect(status.difference).toBe(500) // only the real unbooked deposit remains
expect(status.matched_count).toBe(1)
expect(status.unmatched_transaction_count).toBe(1)
expect(status.is_reconciled).toBe(false)
})
it('reports is_reconciled=true once everything non-ignored is booked, despite ignored rows', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({
data: [
{ amount: 1000, journal_entry_id: 'je-1', reconciliation_method: 'auto_exact', is_ignored: false },
{ amount: 1000, journal_entry_id: null, reconciliation_method: null, is_ignored: true },
],
})
enqueue({ data: [{ id: 'je-1', status: 'posted', source_type: 'bank_import' }] })
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-1' }] })
enqueue({ data: [] })
const status = await getReconciliationStatus(supabase as never, 'company-1')
expect(status.bank_transaction_total).toBe(1000)
expect(status.ignored_transaction_total).toBe(1000)
expect(status.difference).toBe(0)
expect(status.unmatched_transaction_count).toBe(0)
// Before the fix this was unreachable for any company with an ignored row:
// the ignored amount sat in the bank total forever.
expect(status.is_reconciled).toBe(true)
})
it('does not count an ignored row that somehow retains a journal_entry_id as matched', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({
data: [
{ amount: 700, journal_entry_id: 'je-x', reconciliation_method: 'manual', is_ignored: true },
],
})
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
const status = await getReconciliationStatus(supabase as never, 'company-1')
// matched + unmatched partition the reconcilable (non-ignored) set.
expect(status.matched_count).toBe(0)
expect(status.unmatched_transaction_count).toBe(0)
expect(status.ignored_transaction_count).toBe(1)
expect(status.bank_transaction_total).toBe(0)
})
it('reconciles a corrected bank receipt and keeps gl_1930_balance equal to the balance sheet', async () => {
// A +25000 deposit was booked to the wrong counter-account, then corrected
// via the storno flow: the original flips to 'reversed', a storno (credit
+41 -9
View File
@@ -100,7 +100,20 @@ export interface ReconciliationStatus {
* this is a no-op and every figure below is exactly what it always was.
*/
currency: string
/**
* Sum of the window's bank-feed transactions EXCLUDING ignored rows: the
* bank side of the reconciliation. Ignored rows (feed duplicates from a
* PSD2 reconnect, non-business noise) never get a ledger counterpart, so
* counting them here manufactured a permanent unfixable difference; they are
* surfaced separately below instead, mirroring how the opening balance is
* excluded-but-shown.
*/
bank_transaction_total: number
/** Sum of ignored bank transactions in the window. NOT part of
* bank_transaction_total or difference; informational, like the IB. */
ignored_transaction_total: number
/** Number of ignored bank transactions in the window. */
ignored_transaction_count: number
/**
* The real ledger balance on the bank account, incl. IB: computed from the
* SAME `['posted','reversed']` lines the trial balance and balance sheet sum.
@@ -655,10 +668,11 @@ export async function getReconciliationStatus(
includeUnassigned: boolean = true,
): Promise<ReconciliationStatus> {
// Get all transactions in range, scoped to the selected cash account. Ignored
// rows are pulled too so the totals card still reflects what the bank
// actually moved, but they're excluded from the "unmatched" count below: the
// user has explicitly said they don't want them surfacing as something to
// reconcile. Scoping by cash account (not just currency) is what stops a
// rows are pulled too, but only to be COUNTED AND SUMMED separately: they are
// excluded from the bank total, the difference and the matched/unmatched
// counts below, because the user has explicitly said they are not something
// to reconcile (duplicates, non-business noise). Scoping by cash account
// (not just currency) is what stops a
// second same-currency account from inflating bankTotal here.
// Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at 1000
// rows, which would undercount bank_transaction_total for a busy company and
@@ -799,7 +813,7 @@ export async function getReconciliationStatus(
onOrAfterFloor((tx as { date?: string | null }).date),
)
// Bank side: every feed transaction in the (floored) window, full stop. We
// Bank side: every NON-IGNORED feed transaction in the (floored) window. We
// deliberately do NOT special-case rows linked to a reversed entry any more.
// Because the GL side now counts the reversed original, its storno AND the
// correction together (just like the balance sheet), a corrected bank line nets
@@ -809,7 +823,20 @@ export async function getReconciliationStatus(
// transactions.amount is denominated in transactions.currency, and
// scopeTransactionsToAccount pinned that to `currency`, so this total is
// already in the account's own currency: the unit lineAmount() resolves to.
const bankTotal = countedTx.reduce((sum, tx) => sum + (Number(tx.amount) || 0), 0)
//
// Ignored rows are EXCLUDED from the total, exactly as they are excluded from
// the unmatched count: ignoring is the sanctioned handling for feed
// duplicates (a reconnect re-importing history) and non-business noise, and
// by definition an ignored row will never get a ledger counterpart. Counting
// it in the bank total manufactured a permanent difference the user could
// never book away: after a correct duplicate cleanup the card showed a
// six-figure differens over a fully booked account, and is_reconciled was
// unreachable forever. They are surfaced separately (count + sum) instead,
// the same pattern as the opening balance, so nothing is silently hidden.
const reconcilableTx = countedTx.filter((tx) => tx.is_ignored !== true)
const ignoredTx = countedTx.filter((tx) => tx.is_ignored === true)
const bankTotal = reconcilableTx.reduce((sum, tx) => sum + (Number(tx.amount) || 0), 0)
const ignoredTotal = ignoredTx.reduce((sum, tx) => sum + (Number(tx.amount) || 0), 0)
// Ledger lines the account's currency cannot express: a foreign account whose
// lines hold only SEK figures with no per-row rate (SIE imports, pre-FX
@@ -844,10 +871,13 @@ export async function getReconciliationStatus(
// a bank-feed counterpart, so it stays in.
const glPeriodMovement = glBalance - glOpeningBalance
const matchedCount = countedTx.filter((tx) => tx.journal_entry_id !== null).length
// Matched/unmatched partition the RECONCILABLE (non-ignored) set, so
// matched_count + unmatched_transaction_count always equals the number of
// rows behind bank_transaction_total.
const matchedCount = reconcilableTx.filter((tx) => tx.journal_entry_id !== null).length
const unmatchedTransactionCount = countedTx.filter(
(tx) => tx.journal_entry_id === null && tx.is_ignored !== true
const unmatchedTransactionCount = reconcilableTx.filter(
(tx) => tx.journal_entry_id === null
).length
// Unmatched GL lines count (RPC excludes opening_balance, storno and correction
@@ -866,6 +896,8 @@ export async function getReconciliationStatus(
return {
currency,
bank_transaction_total: Math.round(bankTotal * 100) / 100,
ignored_transaction_total: Math.round(ignoredTotal * 100) / 100,
ignored_transaction_count: ignoredTx.length,
gl_1930_balance: Math.round(glBalance * 100) / 100,
gl_1930_period_movement: Math.round(glPeriodMovement * 100) / 100,
gl_1930_opening_balance: Math.round(glOpeningBalance * 100) / 100,
@@ -134,6 +134,39 @@ describe('descriptionsBridge', () => {
expect(descriptionsBridge(' Mataffär Solna ', 'mataffär solna')).toBe(true)
})
it('bridges whitespace-only rendering drift, including a DROPPED space (2026-08-18 reconnect incident)', () => {
// The same bank re-rendered the same transactions with different spacing
// after a PSD2 reconnect; every pair below is a real observed twin shape.
// CRLF vs space:
expect(
descriptionsBridge(
'2025DEC58 BRANDHEROES\r\nURSPRUNGLIGT BELOPP M M: EUR',
'2025DEC58 BRANDHEROES URSPRUNGLIGT BELOPP M M: EUR'
)
).toBe(true)
// Dropped space (collapse-only normalization would still miss this):
expect(
descriptionsBridge(
'005 BaBylissURSPRUNGLIGT BELOPP M M: SEK',
'005 BaByliss URSPRUNGLIGT BELOPP M M: SEK'
)
).toBe(true)
// Trailing padding + reference tail growth still bridges via prefix rule:
expect(
descriptionsBridge(
'BRANDHEROES APSFAK 008URSPRUNGLIGT BELOPP M M: EUR',
'BRANDHEROES APSFAK 008 URSPRUNGLIGT BELOPP M M: EUR 2162943703022614'
)
).toBe(true)
})
it('whitespace stripping does not bridge genuinely distinct references', () => {
expect(descriptionsBridge('REF-AAAA 1111', 'REF-BBBB 2222')).toBe(false)
// A whitespace-only description is still treated as blank (no wildcard).
expect(descriptionsBridge(' \r\n ', 'anything')).toBe(false)
expect(descriptionsBridge(' \r\n ', ' ')).toBe(true)
})
it('does NOT bridge genuinely distinct descriptions sharing a date+amount', () => {
// Distinct reference codes on same-day same-amount rows (e.g. verification
// micro-deposits) must NOT collapse: each is a real transaction.
+16 -3
View File
@@ -139,7 +139,9 @@ export function contentBucketKey(date: string, amount: number | string): string
* ("TIC" → "TIC BG 0000005786439 Bg-bet. via internet", "UTBETALNING" →
* "UTBETALNING Insättning"), so prefix-containment bridges the two where a
* fixed-length prefix *equality* check (the pre-June-2026 scheme) missed and
* re-imported. A blank description carries no signal, so it never bridges a
* re-imported. Whitespace is not part of the signal: both sides are compared
* with ALL whitespace stripped, because banks reformat spacing between API
* sessions (see the inline comment). A blank description carries no signal, so it never bridges a
* *described* row: otherwise an empty title would wildcard-match any
* same-(date,öre) transaction and could silently consume a real one; only two
* blanks bridge each other (date+öre identity). In practice every caller
@@ -158,8 +160,19 @@ export function descriptionsBridge(
a: string | null | undefined,
b: string | null | undefined
): boolean {
const x = (a ?? '').toLowerCase().trim()
const y = (b ?? '').toLowerCase().trim()
// Whitespace is stripped ENTIRELY (not collapsed) before comparing: the same
// bank renders the same transaction with drifting whitespace between API
// sessions ("BaBylissURSPRUNGLIGT" vs "BaByliss URSPRUNGLIGT", CRLF vs
// space), and a collapse-only normalization still misses the dropped-space
// form. Observed in the 2026-08-18 reconnect incident: five historical rows
// re-imported as twins whose descriptions differed only in whitespace, and
// every dedup layer missed them. Stripping is safe for the title-prefix
// bridge too: char-filtering is concatenation-homomorphic, so every existing
// prefix relation is preserved (it can only ADD bridges, never remove one).
// The compare stays confined to a (date, öre) bucket, so the widened match
// can only collapse same-day same-amount rows.
const x = (a ?? '').toLowerCase().replace(/\s+/g, '')
const y = (b ?? '').toLowerCase().replace(/\s+/g, '')
// A blank never wildcards a described row; only two blanks bridge each other.
if (x === '' || y === '') return x === y
return x.startsWith(y) || y.startsWith(x)
+1
View File
@@ -6474,6 +6474,7 @@
"help_bank_reconciliation_ib": "Is a manually booked or imported voucher actually an opening balance? Mark it as IB and it is excluded from the reconciliation and shown separately.",
"help_bank_reconciliation_ignored": "Ignored transactions are hidden from the reconciliation without being booked. They do not affect the balance and can be restored at any time.",
"recon_unmatched_attn": "{count, plural, one {1 unmatched transaction: Preview finds automatic matches.} other {# unmatched transactions: Preview finds automatic matches.}}",
"recon_ignored_note": "{count, plural, one {1 ignored transaction ({amount}) is excluded from the reconciliation. You can restore it under Ignored transactions below.} other {# ignored transactions ({amount}) are excluded from the reconciliation. You can restore them under Ignored transactions below.}}",
"recon_apply_strong": "{count, plural, one {Match 1 strong match} other {Match # strong matches}}",
"switch_report": "Switch report",
"calendar_badge": "Calendar",
+1
View File
@@ -6474,6 +6474,7 @@
"help_bank_reconciliation_ib": "Är en manuellt bokförd eller importerad verifikation egentligen en ingående balans? Märk den som IB så räknas den inte med i avstämningen utan visas separat.",
"help_bank_reconciliation_ignored": "Ignorerade transaktioner döljs från avstämningen utan att bokföras. De påverkar inte saldot och kan återställas när som helst.",
"recon_unmatched_attn": "{count, plural, one {1 omatchad transaktion: Förhandsgranska hittar automatiska träffar.} other {# omatchade transaktioner: Förhandsgranska hittar automatiska träffar.}}",
"recon_ignored_note": "{count, plural, one {1 ignorerad transaktion ({amount}) räknas inte med i avstämningen. Du kan återställa den under Ignorerade transaktioner nedan.} other {# ignorerade transaktioner ({amount}) räknas inte med i avstämningen. Du kan återställa dem under Ignorerade transaktioner nedan.}}",
"recon_apply_strong": "{count, plural, one {Matcha 1 stark träff} other {Matcha # starka träffar}}",
"switch_report": "Byt rapport",
"calendar_badge": "Kalender",
@@ -144,6 +144,7 @@ Returns matched / unmatched counts and the balance delta between the bank ledger
- 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.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
@@ -154,6 +155,8 @@ Response `200`:
{
data: {
bank_transaction_total: number,
ignored_transaction_total: number,
ignored_transaction_count: number,
gl_1930_balance: number,
gl_1930_period_movement: number,
gl_1930_opening_balance: number,