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:
co-authored by
Claude Opus 4.8
parent
b6332e9ff4
commit
a558c75678
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user