From 74942748520cb917816835501d13ed98b5239dd2 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 17 Aug 2026 22:01:20 +0200 Subject: [PATCH] fix(reconciliation): stop counting a stornerad opening balance as the IB (#1647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(reconciliation): stop counting a stornerad opening balance as the IB getReconciliationStatus summed source_type='opening_balance' lines with no status filter while the GL fetch includes reversed entries. A reversed IB and its storno correctly net to zero inside gl_1930_balance (as on the balansräkning), but the reversed IB alone was still counted in gl_1930_opening_balance and subtracted from the period movement, so difference = (bank - gl) + reversed_ib: a phantom diff of exactly the cancelled amount after a perfectly correct rättelse. The IB-floor derivation had the same gap and could raise effectiveFrom to a stray reversed IB, silently dropping early-period movements. Only status='posted' opening_balance lines now count as the IB, for both the opening-balance figure and the floor: the same rule the canonical compute_prior_opening_balances RPC (20260421180000) already applies. Nothing else moves: the reversed pair stays in glBalance where it cancels. Reported via gnubok_feedback 2026-08-16 with the exact formula. Co-Authored-By: Claude Fable 5 * chore: retrigger preview build (Vercel runner hung in Running TypeScript for 45 min, BUILD_EXCEEDED_MAXIMUM_TIME) Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../__tests__/bank-reconciliation.test.ts | 88 +++++++++++++++++++ lib/reconciliation/bank-reconciliation.ts | 19 +++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts index 7486a034..cec86277 100644 --- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts +++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts @@ -1636,6 +1636,94 @@ describe('getReconciliationStatus', () => { expect(status.is_reconciled).toBe(true) }) + // ---------------------------------------------------------------- + // A stornerad opening balance is not an IB + // ---------------------------------------------------------------- + + it('does not count a reversed opening balance (or its storno) as the IB', async () => { + // gnubok_feedback 2026-08-16: aktiekapital was double-booked as an IB on + // A1 (is_opening_balance), then correctly stornerad and re-booked. The + // balansräkning and huvudbok were right, yet the widget kept showing a + // difference of exactly the reversed IB: source_type='opening_balance' + // was summed with no status filter, so the cancelled 25 000 was subtracted + // from the period movement while its storno (source_type 'storno') stayed + // in. difference = (bank - gl) + reversed_ib. Ledger here: reversed IB + // +25 000, storno -25 000, live IB +100 000, one 5 000 receipt. + const { supabase, enqueue } = createQueueMockSupabase() + + enqueue({ + data: [ + { date: '2026-01-15', amount: 5000, journal_entry_id: 'je-recv', reconciliation_method: 'manual' }, + ], + }) + enqueue({ + data: [ + { id: 'je-ib-wrong', status: 'reversed', source_type: 'opening_balance', entry_date: '2026-01-01' }, + { id: 'je-ib-storno', status: 'posted', source_type: 'storno', entry_date: '2026-01-01' }, + { id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' }, + { id: 'je-recv', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-01-15' }, + ], + }) + enqueue({ + data: [ + { debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-ib-wrong' }, + { debit_amount: 0, credit_amount: 25000, journal_entry_id: 'je-ib-storno' }, + { debit_amount: 100000, credit_amount: 0, journal_entry_id: 'je-ib' }, + { debit_amount: 5000, credit_amount: 0, journal_entry_id: 'je-recv' }, + ], + }) + enqueue({ data: [] }) + + const status = await getReconciliationStatus(supabase as never, 'company-1', '2026-01-01') + + // The reversed pair nets to zero inside the ledger balance, as on the BR. + expect(status.gl_1930_balance).toBe(105000) + // Only the live IB counts as the opening balance. + expect(status.gl_1930_opening_balance).toBe(100000) + expect(status.gl_1930_period_movement).toBe(5000) + expect(status.difference).toBe(0) + expect(status.is_reconciled).toBe(true) + }) + + it('does not floor the window at a reversed opening balance dated after the live one', async () => { + // A stray reversed IB dated mid-period must not raise effectiveFrom past + // the real IB and silently drop the period's early movements. + const { supabase, enqueue } = createQueueMockSupabase() + + enqueue({ + data: [ + { date: '2026-01-10', amount: 1000, journal_entry_id: 'je-jan', reconciliation_method: 'manual' }, + { date: '2026-02-10', amount: 2000, journal_entry_id: 'je-feb', reconciliation_method: 'manual' }, + ], + }) + enqueue({ + data: [ + { id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' }, + { id: 'je-jan', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-01-10' }, + { id: 'je-ib-wrong', status: 'reversed', source_type: 'opening_balance', entry_date: '2026-02-01' }, + { id: 'je-ib-storno', status: 'posted', source_type: 'storno', entry_date: '2026-02-01' }, + { id: 'je-feb', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-02-10' }, + ], + }) + enqueue({ + data: [ + { debit_amount: 9000, credit_amount: 0, journal_entry_id: 'je-ib' }, + { debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-jan' }, + { debit_amount: 400, credit_amount: 0, journal_entry_id: 'je-ib-wrong' }, + { debit_amount: 0, credit_amount: 400, journal_entry_id: 'je-ib-storno' }, + { debit_amount: 2000, credit_amount: 0, journal_entry_id: 'je-feb' }, + ], + }) + enqueue({ data: [] }) + + const status = await getReconciliationStatus(supabase as never, 'company-1') + + expect(status.gl_1930_opening_balance).toBe(9000) + expect(status.gl_1930_period_movement).toBe(3000) + expect(status.bank_transaction_total).toBe(3000) + expect(status.difference).toBe(0) + }) + // ---------------------------------------------------------------- // Avstämt requires BOTH a zero net difference AND nothing unidentified // ---------------------------------------------------------------- diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index cf190c48..357c89fb 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -761,8 +761,21 @@ export async function getReconciliationStatus( // dateFrom and that IB date; it only ever RAISES the lower bound, so the // dateFrom SQL pre-filter on both queries above stays valid. In normal use the // UI passes dateFrom = period_start = the IB date, so this is a no-op there. + // + // Only a POSTED opening balance is an IB. A stornerad IB (status 'reversed') + // has been economically nulled by its storno: both lines still sit in + // countedLines and cancel inside glBalance, exactly as on the balansräkning, + // but neither may be treated as the period's IB. Counting the reversed one + // here (and in glOpeningBalance below) re-added the cancelled amount once + // more and manufactured a phantom difference equal to the IB after a + // perfectly correct rättelse. Same rule the canonical opening-balance RPC + // applies (compute_prior_opening_balances, 20260421180000). + const isLiveOpeningBalanceLine = (l: GlLineRow): boolean => { + const entry = entryOf(l) + return entry?.source_type === 'opening_balance' && entry?.status === 'posted' + } const ibDates = fetchedLines - .filter((l) => entryOf(l)?.source_type === 'opening_balance') + .filter(isLiveOpeningBalanceLine) .map((l) => entryOf(l)?.entry_date) .filter((d): d is string => typeof d === 'string' && d.length > 0) // Take the LATEST IB date. The invariant is one opening_balance entry per @@ -811,8 +824,10 @@ export async function getReconciliationStatus( const glBalance = countedLines.reduce((sum, line) => sum + lineAmount(line), 0) // IB is last year's closing position, not a movement with a bank-feed // counterpart, surfaced separately and excluded from the period movement. + // Posted IB lines only (see isLiveOpeningBalanceLine): a reversed IB and its + // storno stay in glBalance where they net to zero. const glOpeningBalance = countedLines - .filter((l) => entryOf(l)?.source_type === 'opening_balance') + .filter(isLiveOpeningBalanceLine) .reduce((sum, line) => sum + lineAmount(line), 0) // Net storno/correction activity on the account this period. Surfaced for // transparency ONLY: it is part of the ledger balance and is INCLUDED in the