* fix(transactions): abort supplier-invoice match when payment voucher fails The match route caught a payment-JE creation failure and proceeded anyway: invoice marked paid with payment_journal_entry_id NULL, a payments row with no voucher, and the bank line linked but unbooked. That half-state is unrecoverable from the UI — mark-paid rejects 'paid' invoices and the match route rejects already-linked transactions (the "user can re-book" comment was wrong). The v1 route was already strict; this aligns the cookie route. A failed voucher now fails the whole match before any state mutation, with bookkeeping errors mapped to their structured codes and a new MATCH_SI_JE_FAILED fallback. Incident: Arcim 2026-06-11 — invoice 20250928 marked paid with no payment voucher because account 3740 was missing from the chart. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): bank-sync supplier-invoice match is a suggestion, not a hard link A high-confidence (>=0.85, unambiguous) supplier-invoice hit at sync time set transactions.supplier_invoice_id directly — without booking a payment or touching the invoice. The half-link then BLOCKED the match route (MATCH_SI_TX_ALREADY_LINKED), stranding the bank line with no path to a payment voucher and the invoice stuck on 'registered'. Sync now always writes potential_supplier_invoice_id; the hard link is reserved for completed matches where the payment voucher is booked. High-confidence hits still drain the matching pool and skip the mapping engine. Incident: Arcim 2026-06-11 — RosholmDell 18299 (29 890 kr) auto-linked at sync, unmatchable afterwards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): seed standard BAS accounts on demand in the engine A minimal company chart routinely lacks accounts that legitimate engine flows reach — 3740 (öres- och kronutjämning) the first time a Bankgiro payment lands a sub-krona off the invoice, 6580 on a first legal invoice. createDraftEntry threw AccountsNotInChartError and turned a standard account into a dead end. The engine now backfills missing accounts from BAS_REFERENCE (full metadata incl. SRU code) before failing. Conservative by design: unknown numbers still throw, and deactivated accounts are never resurrected — deactivation is a deliberate user choice. Concurrent seeding (23505) counts as success. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(supplier-invoices): require explicit expense account, drop the 5010 seed Every new line item (and every AI-prefilled line) was silently seeded with account 5010 Lokalhyra. AI extraction deliberately never suggests accounts, so any invoice saved without touching the field was misbooked as premises rent — legally wrong verifikat that need rättelse to fix. Lines now start with an empty account: the supplier's default_expense_account fills empty rows when set, and submit blocks with a clear toast until every row has an account. Incident: Arcim 2026-06-11 — a legal-services invoice (should be 6580) and a SaaS subscription (should be 5420) both posted to 5010. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(bookkeeping): clarify voucher description suffix to (ankomstnr N) "(ankomst 2)" read as "arrived twice" / a duplicate marker; it is the company-internal sequential arrival counter for supplier invoices. "(ankomstnr 2)" says what the number is. Existing posted vouchers keep their old description (immutable). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): cancel orphaned payment voucher when match loses the CAS race When the payment JE posts but the invoice CAS update matches 0 rows (a concurrent request settled it first), both match routes returned MATCH_SI_NOT_OPEN and left the voucher orphaned in the ledger. mark-paid has always compensated for exactly this case; the compensation is now a shared helper (cancelOrphanedPaymentEntry: cancel + voucher-gap explanation per BFNAR 2013:2) used by all three routes. Flagged by the compliance swarm and the Swedish compliance review on PR #711 — the one finding both converged on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): next_voucher_number user_id fallback for service-role contexts Mirrors 20260421170500 (commit_journal_entry got this fix; its twin did not). Under a service-role client auth.uid() is NULL and the voucher_sequences upsert fails its user_id NOT NULL check before ON CONFLICT can arbitrate — even when the sequence row exists. Every non-interactive caller of the storno/correction path (getNextVoucherNumber → correctEntry) was broken. Fallback: companies.created_by (same source seed_chart_of_accounts uses). Interactive flows still record auth.uid(); DO UPDATE never touches user_id on existing rows. Also restores SET search_path = public, lost when 20260330 recreated the function after the 20260304 hardening. pg-real: new test exercises the RPC on the superuser connection (auth.uid() IS NULL) and asserts sequential numbers + owner attribution. Found live: the Arcim repair script booked payment vouchers fine (commit_journal_entry) but failed on corrections (next_voucher_number). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): harden cancelOrphanedPaymentEntry — never throw, breadcrumb before mutating Two hardenings from the PR #711 review round: - Whole body wrapped in try/catch: the caller is returning the correct CAS-conflict response, so an unexpected client rejection must not replace it with a 500 (best-effort is now a hard guarantee). - The gap-recovery data (series, number, period, explanation) is logged BEFORE the cancel: the cancel and gap insert are separate statements, and a crash between them would otherwise leave a cancelled voucher with no BFNAR 2013:2 gap explanation and no way to reconstruct it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
102 lines
3.9 KiB
TypeScript
102 lines
3.9 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
|
|
// 20260611130000).
|
|
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)
|
|
})
|
|
})
|