Files
accounted/lib/bookkeeping/__tests__/engine.pg.test.ts
T
Jakob Wennberg a558c75678 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>
2026-07-15 14:10:51 +02:00

161 lines
6.1 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import {
insertBalancedLines,
insertDraftJournalEntry,
seedCompany,
} from '@/tests/pg/fixtures'
describe('engine.pg: triggers & RPCs that mocks cannot catch', () => {
it('rejects INSERT into journal_entries when the fiscal period is closed', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany({ isClosed: true })
await expect(
insertDraftJournalEntry({ userId, companyId, fiscalPeriodId }),
).rejects.toThrow(/locked\/closed fiscal period/i)
})
it('commit_journal_entry assigns sequential voucher numbers under concurrency', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryA = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
const entryB = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
await insertBalancedLines(entryA)
await insertBalancedLines(entryB)
// Two dedicated clients so the row-level lock on voucher_sequences is
// actually exercised: not just a single connection serialising calls.
const clientA = await getPool().connect()
const clientB = await getPool().connect()
try {
const [resA, resB] = await Promise.all([
clientA.query<{ voucher_number: number }>(
`SELECT voucher_number FROM public.commit_journal_entry($1::uuid, $2::uuid)`,
[companyId, entryA],
),
clientB.query<{ voucher_number: number }>(
`SELECT voucher_number FROM public.commit_journal_entry($1::uuid, $2::uuid)`,
[companyId, entryB],
),
])
const numbers = [resA.rows[0]!.voucher_number, resB.rows[0]!.voucher_number].sort(
(a, b) => a - b,
)
expect(numbers).toEqual([1, 2])
} finally {
clientA.release()
clientB.release()
}
})
it('rejects UPDATE to a posted journal entry (committed immutability)', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
// Bypass commit_journal_entry by inserting directly as 'posted'. The
// immutability trigger fires on UPDATE, not INSERT, so this is legal
// setup on the superuser connection.
const entryId = await insertDraftJournalEntry({
userId,
companyId,
fiscalPeriodId,
status: 'posted',
voucherNumber: 1,
})
await expect(
getPool().query(
`UPDATE public.journal_entries SET description = 'tampered' WHERE id = $1`,
[entryId],
),
).rejects.toThrow(/Cannot modify a posted journal entry/i)
})
it('next_voucher_number falls back to the company owner when auth.uid() is NULL', async () => {
// The superuser pg connection has no Supabase JWT, so auth.uid() IS NULL:
// exactly the service-role shape (repair scripts, cron) that used to fail
// the voucher_sequences user_id NOT NULL check before ON CONFLICT could
// arbitrate (commit_journal_entry got the fallback in 20260421170500;
// next_voucher_number (the storno/correction path) did not until
// 20260623130000).
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const first = await getPool().query<{ n: number }>(
`SELECT public.next_voucher_number($1::uuid, $2::uuid) AS n`,
[companyId, fiscalPeriodId],
)
const second = await getPool().query<{ n: number }>(
`SELECT public.next_voucher_number($1::uuid, $2::uuid) AS n`,
[companyId, fiscalPeriodId],
)
expect(first.rows[0]!.n).toBe(1)
expect(second.rows[0]!.n).toBe(2)
// Attribution on the sequence row falls back to companies.created_by.
const seq = await getPool().query<{ user_id: string }>(
`SELECT user_id FROM public.voucher_sequences
WHERE company_id = $1::uuid AND fiscal_period_id = $2::uuid AND voucher_series = 'A'`,
[companyId, fiscalPeriodId],
)
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)
})
})