fix(supplier-invoices): payment-match integrity — no more paid-without-voucher half-states (#711)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4253afc343
commit
e978136210
@@ -42,6 +42,8 @@ function createQueueMockSupabase() {
|
||||
// Captures .insert() payloads keyed by table, so tests can assert what was
|
||||
// written (e.g. cash_account_id stamping).
|
||||
const inserts: Record<string, unknown[]> = {}
|
||||
// Same for .update() payloads (e.g. supplier-invoice suggestion linking).
|
||||
const updates: Record<string, unknown[]> = {}
|
||||
|
||||
/**
|
||||
* Push one or more results onto the queue.
|
||||
@@ -66,6 +68,12 @@ function createQueueMockSupabase() {
|
||||
return buildChain(table)
|
||||
}
|
||||
}
|
||||
if (prop === 'update') {
|
||||
return (payload: unknown) => {
|
||||
;(updates[table] ??= []).push(payload)
|
||||
return buildChain(table)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
@@ -77,7 +85,7 @@ function createQueueMockSupabase() {
|
||||
rpc: vi.fn().mockImplementation(() => buildChain('rpc')),
|
||||
}
|
||||
|
||||
return { supabase, enqueue, inserts }
|
||||
return { supabase, enqueue, inserts, updates }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -640,6 +648,58 @@ describe('ingestTransactions', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4b. Supplier-invoice match at sync is ALWAYS a suggestion, never a hard
|
||||
// link. Regression: a high-confidence hit used to set
|
||||
// supplier_invoice_id directly — with no payment voucher booked — which
|
||||
// then BLOCKED the match route (MATCH_SI_TX_ALREADY_LINKED), stranding
|
||||
// the bank line with no path to a payment booking (June 2026 incident:
|
||||
// RosholmDell 18299).
|
||||
// -----------------------------------------------------------------------
|
||||
it('demotes a high-confidence supplier-invoice match to potential_supplier_invoice_id', async () => {
|
||||
const { supabase, enqueue, updates } = createQueueMockSupabase()
|
||||
const raw = makeRaw({
|
||||
date: '2026-06-08',
|
||||
amount: -29890,
|
||||
description: 'RosholmDell Advo BG 0000007746514 Bg-bet. via internet',
|
||||
})
|
||||
const inserted = makeTransaction({
|
||||
id: 'tx-rd',
|
||||
amount: -29890,
|
||||
date: '2026-06-08',
|
||||
external_id: raw.external_id,
|
||||
})
|
||||
// One unpaid invoice, exact amount, tx date inside the credit window →
|
||||
// Pass-3 amount_date match at 0.85, unambiguous (previously: hard link).
|
||||
const supplierInvoice = {
|
||||
id: 'si-rd',
|
||||
status: 'registered',
|
||||
total: 29890,
|
||||
remaining_amount: 29890,
|
||||
invoice_date: '2026-06-05',
|
||||
due_date: '2026-07-05',
|
||||
payment_reference: null,
|
||||
supplier: { name: 'RosholmDell Advokatbyrå AB' },
|
||||
}
|
||||
|
||||
enqueue({ data: [], error: null }) // booked map
|
||||
enqueue({ data: [], error: null }) // unbooked bank-synced map
|
||||
enqueue({ data: [supplierInvoice], error: null }) // supplier invoices pool
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
enqueue({ data: inserted, error: null }) // insert
|
||||
enqueue({ data: null, error: null }) // suggestion update
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.auto_matched_invoices).toBe(1)
|
||||
const txUpdates = (updates['transactions'] ?? []) as Record<string, unknown>[]
|
||||
expect(txUpdates).toHaveLength(1)
|
||||
expect(txUpdates[0]).toEqual({ potential_supplier_invoice_id: 'si-rd' })
|
||||
// The hard link is reserved for completed matches (payment voucher booked).
|
||||
expect(txUpdates.some((u) => 'supplier_invoice_id' in u)).toBe(false)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. Does not attempt invoice matching for expenses (amount < 0)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
+23
-31
@@ -449,46 +449,38 @@ export async function ingestTransactions(
|
||||
)
|
||||
|
||||
if (match && !matchedSupplierInvoiceIds.has(match.supplierInvoice.id)) {
|
||||
// Ambiguous amount_date hits (several same-amount invoices in-window)
|
||||
// are demoted to suggestions — auto-linking the wrong one is worse
|
||||
// than asking the user to pick.
|
||||
// ALWAYS a suggestion (potential_supplier_invoice_id), never a hard
|
||||
// link. supplier_invoice_id is reserved for completed matches — the
|
||||
// match route books the payment voucher when it sets it. A sync-time
|
||||
// hard link booked nothing, left the invoice open, and then BLOCKED
|
||||
// the match route (MATCH_SI_TX_ALREADY_LINKED), stranding the
|
||||
// transaction with no path to a payment voucher.
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ potential_supplier_invoice_id: match.supplierInvoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', {
|
||||
supplierInvoiceId: match.supplierInvoice.id,
|
||||
matchConfidence: match.confidence,
|
||||
matchMethod: match.matchMethod,
|
||||
})
|
||||
|
||||
if (match.confidence >= 0.85 && !match.ambiguous) {
|
||||
// Auto-link at high confidence
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ supplier_invoice_id: match.supplierInvoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
// Log the match THEN drain the pool (captures which invoice was matched)
|
||||
logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', {
|
||||
supplierInvoiceId: match.supplierInvoice.id,
|
||||
matchConfidence: match.confidence,
|
||||
matchMethod: match.matchMethod,
|
||||
})
|
||||
|
||||
// Drain the pool — prevents next transaction from matching same invoice
|
||||
// High-confidence unambiguous hit: drain the pool so the next
|
||||
// transaction can't claim the same invoice, and skip the mapping
|
||||
// engine — auto-categorization would create an orphaned journal
|
||||
// entry that conflicts with the eventual payment booking.
|
||||
unpaidSupplierInvoices = unpaidSupplierInvoices.filter(
|
||||
inv => inv.id !== match.supplierInvoice.id
|
||||
)
|
||||
matchedSupplierInvoiceIds.add(match.supplierInvoice.id)
|
||||
|
||||
result.auto_matched_invoices++
|
||||
// Skip mapping engine — transaction has a supplier invoice match
|
||||
continue
|
||||
} else {
|
||||
// Store as suggestion at lower confidence (0.70–0.85)
|
||||
// Do NOT drain pool for suggestions — they are tentative
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ potential_supplier_invoice_id: match.supplierInvoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', {
|
||||
supplierInvoiceId: match.supplierInvoice.id,
|
||||
matchConfidence: match.confidence,
|
||||
matchMethod: match.matchMethod,
|
||||
})
|
||||
}
|
||||
// Lower confidence (0.70–0.85) or ambiguous: tentative — do NOT
|
||||
// drain the pool.
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — continue processing
|
||||
|
||||
Reference in New Issue
Block a user