fix(reconciliation): stop counting a stornerad opening balance as the IB (#1647)

* 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 <noreply@anthropic.com>

* chore: retrigger preview build (Vercel runner hung in Running TypeScript for 45 min, BUILD_EXCEEDED_MAXIMUM_TIME)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

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-17 22:01:20 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent a447b29210
commit 7494274852
2 changed files with 105 additions and 2 deletions
@@ -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
// ----------------------------------------------------------------