fix(bookkeeping): clear the period IB link when stornoing an opening balance (#1022)

* fix(bookkeeping): clear the period IB link when stornoing an opening balance

Reversing a period's opening-balance verifikat left
fiscal_periods.opening_balance_entry_id pointing at the reversed entry, and
nothing reads that pointer's status. The storno was a no-op where it mattered:

- getOpeningBalances() reads the linked entry's lines with no status filter,
  so the Balansrapport kept showing the cancelled IB.
- Year-end blocks while the pointer is non-null and tells the user to
  "reverse it before re-running year-end": advice the storno could never
  satisfy. delete_last_voucher and the opening-balance/correct route both
  refuse an already-reversed entry, so there was no in-app way out.

reverseEntry now drops the link, mirroring the bank-transaction unlink
directly above it. getOpeningBalances falls through to the duplicate-safe
compute_prior_opening_balances RPC, and year-end can re-book the IB. This
also closes the documented residual edge in opening-balance/correct (storno
succeeded, relink failed) and makes runYearEnd's rollback comment true.

Two statements, not one: enforce_opening_balance_immutability rejects a
pointer change while opening_balances_set is still true. Covered by a pg-real
test, since a mocked client happily accepts the single-statement version that
the real trigger rejects.

Found via support: a user could not close 2025 because bogus 2026 opening
balances from a SIE import would not go away.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: record the storno/IB-link decision in DECISIONS.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-15 14:10:51 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b6332e9ff4
commit a558c75678
4 changed files with 221 additions and 0 deletions
+119
View File
@@ -750,3 +750,122 @@ describe('reverseEntry: bank transaction unlink', () => {
expect(txFilters).toMatchObject({ company_id: 'company-1', journal_entry_id: 'entry-1' })
})
})
describe('reverseEntry: opening balance unlink', () => {
// Reversing a period's IB verifikat must also drop the period's pointer to
// it. getOpeningBalances() reads the linked entry's lines with no status
// filter, so a still-linked cancelled IB keeps showing in the Balansrapport,
// and year-end refuses to run while the pointer is non-null: the storno its
// own error message asks for could never satisfy it. See engine.pg.test.ts
// for why the flag must fall before the pointer.
function buildSupabase(sourceType: string) {
const original = {
id: 'entry-1',
company_id: 'company-1',
status: 'posted',
fiscal_period_id: 'period-1',
voucher_series: 'A',
voucher_number: 1,
entry_date: '2026-01-01',
description: 'Ingående balanser från SIE-import',
source_type: sourceType,
source_id: null,
lines: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '2350', debit_amount: 0, credit_amount: 1000 },
],
}
const reversal = { id: 'reversal-1', reverses_id: 'entry-1', source_type: 'storno' }
let jeCall = 0
const jeResults = [
{ data: original, error: null },
{ data: reversal, error: null },
{ data: null, error: null },
{ data: [{ id: 'entry-1' }], error: null },
{ data: { ...reversal, lines: [] }, error: null },
]
function jeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'update', 'insert']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => jeResults[jeCall++])
b.then = (resolve: (v: unknown) => void) => resolve(jeResults[jeCall++])
return b
}
const fpUpdates: unknown[] = []
const fpFilters: Record<string, unknown>[] = []
const supabase = {
rpc: vi.fn().mockResolvedValue({ data: 2, error: null }),
from: vi.fn().mockImplementation((table: string) => {
if (table === 'journal_entries') return jeBuilder()
if (table === 'chart_of_accounts') {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) b[m] = vi.fn().mockReturnValue(b)
b.then = (resolve: (v: unknown) => void) =>
resolve({
data: [
{ id: 'acc-1930', account_number: '1930' },
{ id: 'acc-2350', account_number: '2350' },
],
error: null,
})
return b
}
if (table === 'journal_entry_lines') {
return { insert: vi.fn().mockResolvedValue({ error: null }) }
}
if (table === 'fiscal_periods') {
const b: Record<string, unknown> = {}
const filters: Record<string, unknown> = {}
b.update = vi.fn().mockImplementation((payload: unknown) => {
fpUpdates.push(payload)
fpFilters.push(filters)
return b
})
b.eq = vi.fn().mockImplementation((col: string, val: unknown) => {
filters[col] = val
return b
})
b.then = (resolve: (v: unknown) => void) => resolve({ error: null })
return b
}
if (table === 'transactions') {
const b: Record<string, unknown> = {}
for (const m of ['update', 'eq']) b[m] = vi.fn().mockReturnValue(b)
b.then = (resolve: (v: unknown) => void) => resolve({ error: null })
return b
}
return createMockChain()
}),
}
return { supabase, fpUpdates, fpFilters }
}
it('clears the period IB link, flag before pointer, for an opening_balance entry', async () => {
const { supabase, fpUpdates, fpFilters } = buildSupabase('opening_balance')
const result = await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
expect(result.id).toBe('reversal-1')
// Order matters: enforce_opening_balance_immutability rejects the pointer
// write while opening_balances_set is still true.
expect(fpUpdates).toEqual([{ opening_balances_set: false }, { opening_balance_entry_id: null }])
// Scoped to this entry, so a period pointing elsewhere is untouched.
for (const f of fpFilters) {
expect(f).toMatchObject({ company_id: 'company-1', opening_balance_entry_id: 'entry-1' })
}
})
it('leaves fiscal_periods untouched for a non-opening_balance entry', async () => {
const { supabase, fpUpdates } = buildSupabase('manual')
await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
expect(fpUpdates).toEqual([])
})
})