diff --git a/DECISIONS.md b/DECISIONS.md index bb01e34f..67b8252b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1082,4 +1082,5 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts index 27146b17..f431b43e 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts @@ -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, diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx index b7d5bf7e..a083b5d8 100644 --- a/components/reports/BankReconciliationView.tsx +++ b/components/reports/BankReconciliationView.tsx @@ -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)} + {status.ignored_transaction_count > 0 && ( +

+ {t('recon_ignored_note', { + count: status.ignored_transaction_count, + amount: formatCurrency(status.ignored_transaction_total, accountCurrency), + })} +

+ )} {status.gl_1930_opening_balance !== 0 && (

Ingående balans (IB) på :{' '} diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts index cec86277..70f3bc58 100644 --- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts +++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts @@ -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 diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index 357c89fb..25733c8e 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -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 { // 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, diff --git a/lib/transactions/__tests__/external-id.test.ts b/lib/transactions/__tests__/external-id.test.ts index 377b4299..46b6fc4f 100644 --- a/lib/transactions/__tests__/external-id.test.ts +++ b/lib/transactions/__tests__/external-id.test.ts @@ -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. diff --git a/lib/transactions/external-id.ts b/lib/transactions/external-id.ts index 7e8f4c4d..ebc67e25 100644 --- a/lib/transactions/external-id.ts +++ b/lib/transactions/external-id.ts @@ -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) diff --git a/messages/en.json b/messages/en.json index ac6fd80e..4093c0bf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 1365282a..7ed6a69b 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/skills/accounted-api/references/banking.md b/skills/accounted-api/references/banking.md index c3aa8c5f..46ed8f80 100644 --- a/skills/accounted-api/references/banking.md +++ b/skills/accounted-api/references/banking.md @@ -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,