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
@@ -98,4 +98,63 @@ describe('engine.pg: triggers & RPCs that mocks cannot catch', () => {
)
expect(seq.rows[0]!.user_id).toBe(userId)
})
// reverseEntry() clears the period's IB link when it stornos an
// opening_balance entry. enforce_opening_balance_immutability dictates the
// shape of that write, and only a real Postgres can prove the ordering: a
// mocked client accepts the single-statement version that the trigger
// rejects, which is how a "fixed" storno can still leave the period pinned
// to a cancelled IB (blocking year-end forever).
it('enforce_opening_balance_immutability forces a two-step IB unlink', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const ibEntryId = await insertDraftJournalEntry({
userId,
companyId,
fiscalPeriodId,
status: 'posted',
voucherNumber: 1,
})
// Linking is legal: the trigger only guards the pointer once it is set.
await getPool().query(
`UPDATE public.fiscal_periods
SET opening_balance_entry_id = $2::uuid, opening_balances_set = true
WHERE id = $1::uuid`,
[fiscalPeriodId, ibEntryId],
)
// Clearing both columns at once still reads OLD.opening_balances_set =
// true, so the trigger rejects it. This is the write reverseEntry must
// never emit.
await expect(
getPool().query(
`UPDATE public.fiscal_periods
SET opening_balance_entry_id = NULL, opening_balances_set = false
WHERE id = $1::uuid`,
[fiscalPeriodId],
),
).rejects.toThrow(/opening balances are immutable once set/i)
// Flag first, pointer second: the order reverseEntry uses.
await getPool().query(
`UPDATE public.fiscal_periods SET opening_balances_set = false WHERE id = $1::uuid`,
[fiscalPeriodId],
)
await getPool().query(
`UPDATE public.fiscal_periods SET opening_balance_entry_id = NULL WHERE id = $1::uuid`,
[fiscalPeriodId],
)
const period = await getPool().query<{
opening_balance_entry_id: string | null
opening_balances_set: boolean
}>(
`SELECT opening_balance_entry_id, opening_balances_set
FROM public.fiscal_periods WHERE id = $1::uuid`,
[fiscalPeriodId],
)
expect(period.rows[0]!.opening_balance_entry_id).toBeNull()
expect(period.rows[0]!.opening_balances_set).toBe(false)
})
})