feat(transactions): match overshoot guards + supplier voucher linking (#602)
* feat(transactions): match overshoot guards + supplier voucher linking
Three changes that together close the "I can't link a bank transaction
to an already-booked verifikat on the supplier side" gap and fix a
latent data-corruption bug on the per-tx match endpoints.
1. fix: clamp paid_amount on match endpoints when tx > remaining
/api/transactions/[id]/match-{invoice,supplier-invoice} previously
used transaction.amount wholesale as the paid amount, pushing
invoice.paid_amount past invoice.total whenever the bank tx was
larger than what was owed. Both endpoints now reject with
MATCH_AMOUNT_EXCEEDS_REMAINING / MATCH_SI_AMOUNT_EXCEEDS_REMAINING
and a structured { transaction_amount, remaining_amount, excess }
payload that points the user at the future split-payment flow.
FX branch already clamps to invoice.remaining_amount and is
unchanged.
2. feat: supplier-side "link existing verifikat" (mirror of #591)
lib/invoices/supplier-voucher-matching.ts mirrors the customer
voucher-matching module: finds posted JEs that debit 2440
(Leverantörsskulder), validates currency + remaining-amount, and
atomically links them as supplier_invoice_payments rows. New
/api/supplier-invoices/[id]/{voucher-candidates,link-to-voucher}
routes wrap it. LinkVoucherPicker gains a mode='supplier_invoice'
prop so the same component renders both flows. The supplier-invoice
mark-paid dialog now uses Tabs ("Ny betalning" / "Befintlig
verifikation") to match the customer-side UX.
3. infra: transaction_voucher_links junction + denorm guard
Foundation migration for upcoming multi-tx ↔ multi-voucher flows.
Adds the junction table (with RLS, updated_at, indexes), a
block_contradictory_invoice_denorm trigger on transactions that
refuses to set invoice_id/supplier_invoice_id to a value that
contradicts an existing payment row, and is_transaction_booked(uuid)
as a single source of truth for "is this tx anchored?" once
multi-allocation leaves denorm columns NULL. No application code
uses these yet — they unlock the batch allocation and bulk-book
flows in follow-up PRs.
Tests: 98 unit tests pass across the touched paths (match-invoice,
match-supplier-invoice, supplier-voucher-matching, link-to-voucher).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(supplier-invoices): PR review — atomic link RPC, computeRemaining edge case, pg-real tests
Addresses the three real issues raised by Greptile on PR #602.
1. (P1) Atomic supplier voucher linking — new
link_supplier_invoice_to_voucher PL/pgSQL RPC. The TS-side
linkSupplierInvoiceToVoucher() previously did UPDATE-then-INSERT with
a manual unconditional rollback. Under concurrent linking against the
same invoice, request A's rollback could overwrite a sibling B's
successful write while leaving B's payment row in place. Moving both
writes into a single PG transaction (one RPC call) lets PG's own
rollback handle the failure path correctly. TS wrapper now just
translates the structured RPC return into the lib's Result type.
2. (P1) pg-real tests — tests/pg/transaction_voucher_links.pg.test.ts.
CLAUDE.md mandates *.pg.test.ts for any PR adding a trigger, RPC, or
RLS. The Phase 1A foundation migration added all three but had no
pg-real coverage. Tests now cover:
- trg_block_contradictory_invoice_denorm refusing contradictory
UPDATEs on invoice_id and supplier_invoice_id
- the same trigger PERMITTING a matching UPDATE (no false positives)
- is_transaction_booked() returning true via journal_entry_id, via
invoice_payments, and via transaction_voucher_links rows.
3. (P2) computeRemaining edge case — trust remaining_amount whenever
the column is non-null (including the legitimate 0 for fully-paid
invoices). The old "> 0" guard fell through to total - paid_amount,
which under rounding drift could compute a tiny positive residue and
slip a fully-paid invoice past LINK_SI_VOUCHER_INVOICE_FULLY_PAID.
The fourth Greptile comment (overdue invoices silently get no
candidates) was a misread: 'overdue' IS in the open-state list at
route.ts:35. No code change needed there.
Tests: 100 unit tests pass (16 in the directly-touched paths).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(supplier-invoices): PR review round 2 — broaden AP range, log event failures
Addresses the actionable findings from the compliance-swarm and
Swedish-accounting-compliance bot reviews on PR #602.
1. (swedish-accounting-compliance, high) AP account hardcoded to 2440
rejected legitimate samlingsverifikationer that debit 2441
(Leverantörsskulder i utländsk valuta), 2443 (Skuldfakturor), etc.
BAS 2026 reserves the full 2440–2449 range for Leverantörsskulder.
The TS-side AP_ACCOUNT constant becomes AP_ACCOUNT_PREFIX ('244')
used with .like() and .startsWith(). The PL/pgSQL RPC's
account_number filter becomes LIKE '244%'. The
LINK_SI_VOUCHER_NO_AP_DEBIT error message updates to reference the
244x range with examples.
2. (ISO 27001:2022 A.8.15 / OWASP V16) Empty catch on the
supplier_invoice.paid event emission now logs with log.warn so a
failure in the downstream reminder/audit subscriber leaves an
auditable trail without blocking the response.
3. (GDPR Art.5(1)(c)) Documented design rationale for retaining
select('*') on the post-link invoice re-fetch: the
supplier_invoice.paid event payload is typed as
`supplierInvoice: SupplierInvoice` in lib/events/types.ts, narrowing
would break the subscriber contract. The event stays in-process
and consumers legitimately need the full context.
Skipped findings:
- V8.2.1 ownership concerns: route + RPC already filter by
company_id from withRouteContext; the RPC's WHERE clause covers it.
- DELETE policy scoping: matches the gnubok pattern across all
company-scoped tables — any member with write access manages records.
- transaction_id = NULL on the voucher-link path: by design — the
flow has no bank tx (the voucher's 1930 line represents it).
- Reverse-charge VAT (2614/2647) validation on linked vouchers:
real concern but invasive change; tracked for follow-up.
- Storno-chain integrity (linking the original of a storno pair):
edge case; tracked for follow-up.
Tests: 26 unit tests pass in the directly-touched paths. RPC patch
applied to remote via Supabase MCP.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a586cc8a58
commit
7bcd46d503
@@ -394,6 +394,13 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_en:
|
||||
'The candidate journal entry echoed in expected_journal_entry_id does not match the one detected at request time. Re-run the duplicate-payment pre-flight to obtain the current candidate, then retry.',
|
||||
},
|
||||
MATCH_AMOUNT_EXCEEDS_REMAINING: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Transaktionsbeloppet är större än fakturans återstående belopp. Dela betalningen och fördela överskottet på en eller flera andra fakturor.',
|
||||
message_en:
|
||||
'Transaction amount exceeds the invoice remaining amount. Use the split-payment flow to allocate the excess across one or more other invoices.',
|
||||
},
|
||||
}
|
||||
|
||||
const LINK_TX_JE: Record<string, StructuredErrorEntry> = {
|
||||
@@ -479,6 +486,13 @@ const MATCH_SI: Record<string, StructuredErrorEntry> = {
|
||||
message_en:
|
||||
'Cash accounting does not support exchange-rate differences. Switch to accrual or book the FX difference manually.',
|
||||
},
|
||||
MATCH_SI_AMOUNT_EXCEEDS_REMAINING: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Transaktionsbeloppet är större än leverantörsfakturans återstående belopp. Dela betalningen och fördela överskottet på en eller flera andra leverantörsfakturor.',
|
||||
message_en:
|
||||
'Transaction amount exceeds the supplier invoice remaining amount. Use the split-payment flow to allocate the excess across one or more other supplier invoices.',
|
||||
},
|
||||
TX_UNCATEGORIZE_NOT_BOOKED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Transaktionen är inte bokförd. Det finns inget att av-kategorisera.',
|
||||
@@ -1725,6 +1739,69 @@ const LINK_INVOICE_VOUCHER: Record<string, StructuredErrorEntry> = {
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Link SUPPLIER invoice to an existing posted verifikat (no new JE)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const LINK_SI_VOUCHER: Record<string, StructuredErrorEntry> = {
|
||||
LINK_SI_VOUCHER_INVOICE_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Leverantörsfakturan kunde inte hittas.',
|
||||
message_en: 'Supplier invoice not found.',
|
||||
},
|
||||
LINK_SI_VOUCHER_VOUCHER_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Verifikationen kunde inte hittas.',
|
||||
message_en: 'Journal entry not found.',
|
||||
},
|
||||
LINK_SI_VOUCHER_NOT_POSTED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Verifikationen är inte bokförd. Endast bokförda verifikationer kan länkas som betalning.',
|
||||
message_en: 'Journal entry is not posted. Only posted entries can be linked as a payment.',
|
||||
},
|
||||
LINK_SI_VOUCHER_NO_AP_DEBIT: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Verifikationen debiterar inget leverantörsskuldskonto (244x). Rätta bokföringen först med en stornoverifikation som debiterar t.ex. 2440 (SEK) eller 2441 (utländsk valuta), via gnubok_correct_entry.',
|
||||
message_en:
|
||||
'The journal entry does not debit any accounts-payable account in the 244x range (e.g. 2440 SEK, 2441 foreign currency). Correct the booking first via a storno+correction (gnubok_correct_entry).',
|
||||
remediation: {
|
||||
description:
|
||||
'Use gnubok_correct_entry to storno the existing voucher and re-book the payment as Dr 244x / Cr 1930, then link the corrected voucher.',
|
||||
tool: 'gnubok_correct_entry',
|
||||
},
|
||||
},
|
||||
LINK_SI_VOUCHER_ALREADY_LINKED: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Verifikationen är redan länkad till den här leverantörsfakturan.',
|
||||
message_en: 'This journal entry is already linked to this supplier invoice.',
|
||||
},
|
||||
LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Verifikationens leverantörsskuldsdebitering är större än leverantörsfakturans återstående belopp. Verifikationen täcker fler fakturor — välj en annan verifikation eller rätta beloppet först.',
|
||||
message_en:
|
||||
'The voucher\'s AP debit exceeds the supplier invoice\'s remaining balance. Split the voucher across multiple supplier invoices via gnubok_correct_entry first, or pick a different voucher.',
|
||||
},
|
||||
LINK_SI_VOUCHER_CURRENCY_MISMATCH: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Verifikationens valuta matchar inte leverantörsfakturans. Endast verifikationer i fakturans valuta kan länkas.',
|
||||
message_en: 'The voucher\'s currency does not match the supplier invoice currency.',
|
||||
},
|
||||
LINK_SI_VOUCHER_INVOICE_FULLY_PAID: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Leverantörsfakturan har redan slutbetalats. Inget mer behöver länkas.',
|
||||
message_en: 'Supplier invoice is already fully paid.',
|
||||
},
|
||||
LINK_SI_VOUCHER_DB_ERROR: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Databasfel under länkning. Försök igen.',
|
||||
message_en: 'Database error while linking the voucher. Please retry.',
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Combined registry
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -1736,6 +1813,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
|
||||
...MATCH_INVOICE,
|
||||
...LINK_TX_JE,
|
||||
...LINK_INVOICE_VOUCHER,
|
||||
...LINK_SI_VOUCHER,
|
||||
...MATCH_SI,
|
||||
...INVOICE,
|
||||
...SUPPLIER_INVOICE,
|
||||
|
||||
Reference in New Issue
Block a user