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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
a447b29210
commit
7494274852
@@ -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
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user