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
// ----------------------------------------------------------------
+17 -2
View File
@@ -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