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
+42
View File
@@ -865,6 +865,48 @@ export async function reverseEntry(
log.error('failed to unlink transactions from reversed entry', unlinkError, { entryId })
}
// Same hazard one table over: a period whose opening_balance_entry_id still
// points at the entry we just reversed. getOpeningBalances() reads the linked
// entry's lines directly with no status filter, so the Balansrapport would go
// on showing a cancelled IB, and year-end refuses to run while the link is
// non-null ("Next fiscal period already has opening balance entry posted;
// reverse it before re-running year-end"): advice the storno itself could
// never satisfy, leaving no in-app way out. Clearing the link falls
// getOpeningBalances through to the duplicate-safe
// compute_prior_opening_balances RPC, and lets year-end re-book the IB.
//
// Two statements, not one: enforce_opening_balance_immutability rejects any
// UPDATE that changes opening_balance_entry_id while OLD.opening_balances_set
// is still true, so the flag must fall first (same order, and same reason, as
// the replace_period_opening_balance_link RPC). Both are scoped to this
// entryId, so a period already pointing elsewhere is untouched and callers
// that storno an old IB then relink a fresh one (opening-balance/correct)
// still win: they relink after this returns.
if (original.source_type === 'opening_balance') {
const { error: obFlagError } = await supabase
.from('fiscal_periods')
.update({ opening_balances_set: false })
.eq('company_id', companyId)
.eq('opening_balance_entry_id', entryId)
if (obFlagError) {
log.error('failed to clear opening_balances_set on reversed IB period', obFlagError, {
entryId,
})
} else {
const { error: obUnlinkError } = await supabase
.from('fiscal_periods')
.update({ opening_balance_entry_id: null })
.eq('company_id', companyId)
.eq('opening_balance_entry_id', entryId)
if (obUnlinkError) {
log.error('failed to unlink reversed opening balance entry from period', obUnlinkError, {
entryId,
})
}
}
}
// If this was a payment entry, sync the linked invoice/supplier-invoice status.
// Helper is shared with the DELETE journal entry route so both code paths leave
// the invoice in a consistent state (BFL 5 kap 5§ requires GL reversal; this