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:
Jakob Wennberg
2026-06-11 10:44:15 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 4253afc343
commit e978136210
21 changed files with 891 additions and 87 deletions
@@ -6,6 +6,7 @@ import {
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { validateBody } from '@/lib/api/validate'
@@ -234,27 +235,10 @@ export const POST = withRouteContext(
// CAS guard: another request paid the invoice between our read and write.
// Cancel the orphaned JE and document the voucher gap.
if (journalEntryId) {
const { data: orphan } = await supabase
.from('journal_entries')
.select('fiscal_period_id, voucher_series, voucher_number')
.eq('id', journalEntryId)
.single()
await supabase
.from('journal_entries')
.update({ status: 'cancelled' })
.eq('id', journalEntryId)
if (orphan) {
await supabase.from('voucher_gap_explanations').insert({
company_id: companyId,
fiscal_period_id: orphan.fiscal_period_id,
voucher_series: orphan.voucher_series || 'A',
gap_number: orphan.voucher_number,
explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
created_by: user.id,
})
}
await cancelOrphanedPaymentEntry(
supabase, companyId!, user.id, journalEntryId,
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
)
}
return errorResponseFromCode('SI_PAID_ALREADY', opLog, {
requestId,
@@ -4,6 +4,7 @@ import {
createMockRouteParams,
parseJsonResponse,
} from '@/tests/helpers'
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
@@ -383,3 +384,54 @@ describe('POST /api/transactions/[id]/match-supplier-invoice — cash method + F
expect(body.remaining_amount).toBe(0.25)
})
})
describe('POST /api/transactions/[id]/match-supplier-invoice — payment JE failure aborts', () => {
// Regression: the route used to catch a JE-creation failure and proceed —
// marking the invoice paid with NO payment voucher. That half-state is
// unrecoverable (mark-paid rejects 'paid', match rejects linked txs), so a
// failed voucher must now fail the whole match before any state mutation.
it('returns 500 MATCH_SI_JE_FAILED and mutates nothing when the engine throws (pure-SEK path)', async () => {
// Only the 3 reads enqueued — if the route (incorrectly) proceeded to the
// invoice update, the empty queue would surface as MATCH_SI_NOT_OPEN.
enqueueHappyPath({
transaction: { amount: -29890, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 29890 },
})
mockCreateJournalEntry.mockRejectedValue(new Error('boom'))
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(status).toBe(500)
expect(body.error.code).toBe('MATCH_SI_JE_FAILED')
})
it('maps a bookkeeping error (missing account) to its structured code and aborts', async () => {
enqueueHappyPath({
transaction: { amount: -11231, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 11231.25 },
})
mockCreateJournalEntry.mockRejectedValue(new AccountsNotInChartError(['3740']))
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { account_numbers?: string[] } }
}>(res)
expect(status).toBeGreaterThanOrEqual(400)
expect(body.error.code).toBe(new AccountsNotInChartError(['3740']).code)
expect(body.error.details?.account_numbers).toEqual(['3740'])
})
it('returns MATCH_SI_JE_FAILED when the engine resolves without an entry', async () => {
enqueueHappyPath({
transaction: { amount: -100, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 100 },
})
mockCreateJournalEntry.mockResolvedValue(null)
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
expect(status).toBe(500)
expect(body.error.code).toBe('MATCH_SI_JE_FAILED')
})
})
@@ -4,10 +4,10 @@ import {
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { buildSupplierPaymentClearingLines } from '@/lib/bookkeeping/supplier-payment-lines'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { planSupplierPayment } from '@/lib/invoices/apply-supplier-payment'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
@@ -219,7 +219,6 @@ export const POST = withRouteContext(
: `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
let journalEntryId: string | null = null
let journalEntryError: string | null = null
try {
if (customLines) {
@@ -298,13 +297,21 @@ export const POST = withRouteContext(
}
} catch (err) {
txLog.error('failed to create supplier invoice payment journal entry', err as Error)
// Bookkeeping errors with structured codes get a Swedish translation;
// otherwise pass-through. Match still proceeds — the user can re-book.
// A failed payment voucher must fail the whole match. Proceeding used to
// mark the invoice paid with NO voucher — an unrecoverable half-state:
// mark-paid rejects 'paid' invoices and this route rejects linked
// transactions, so no flow could ever complete the booking afterwards.
if (isBookkeepingError(err)) {
journalEntryError = getErrorMessage(err, { context: 'supplier_invoice' })
} else {
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
return errorResponse(err, txLog, { requestId })
}
return errorResponseFromCode('MATCH_SI_JE_FAILED', txLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
if (!journalEntryId) {
return errorResponseFromCode('MATCH_SI_JE_FAILED', txLog, { requestId })
}
// Ledger update from the plan computed up front. An öre-absorbed settlement
@@ -332,6 +339,13 @@ export const POST = withRouteContext(
}
if (!updatedRows || updatedRows.length === 0) {
// CAS guard: the invoice was settled by a concurrent request between
// our read and write. The payment voucher we just posted belongs to no
// payment — cancel it and document the gap (mirrors mark-paid).
await cancelOrphanedPaymentEntry(
supabase, companyId!, user.id, journalEntryId,
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
)
return errorResponseFromCode('MATCH_SI_NOT_OPEN', txLog, { requestId })
}
@@ -391,19 +405,12 @@ export const POST = withRouteContext(
txLog.warn('supplier_invoice.match_confirmed event emission failed', err as Error)
}
if (journalEntryError) {
txLog.warn('supplier invoice match recorded but payment JE failed', {
message: journalEntryError,
})
}
return NextResponse.json({
success: true,
invoice_status: newStatus,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
journal_entry_id: journalEntryId,
...(journalEntryError ? { journal_entry_error: journalEntryError } : {}),
})
},
{ requireWrite: true },
@@ -97,7 +97,7 @@ registerEndpoint({
voucher_series: 'A',
voucher_number: 142,
entry_date: '2026-05-12',
description: 'Levfaktura 2026-1234, Office Depot AB (ankomst 42)',
description: 'Levfaktura 2026-1234, Office Depot AB (ankomstnr 42)',
status: 'posted',
source_type: 'supplier_invoice_registered',
created_at: '2026-05-13T15:00:00Z',
@@ -11,6 +11,7 @@ import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
@@ -350,6 +351,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
.select('id')
if (updateInvErr) return v1ErrorResponse(updateInvErr, txLog, { requestId: ctx.requestId })
if (!updatedRows || updatedRows.length === 0) {
// CAS guard: the invoice was settled by a concurrent request between
// our read and write. The payment voucher we just posted belongs to no
// payment — cancel it and document the gap (mirrors mark-paid).
if (journalEntryId) {
await cancelOrphanedPaymentEntry(
ctx.supabase, ctx.companyId!, ctx.userId, journalEntryId,
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
)
}
return v1ErrorResponseFromCode('MATCH_SI_NOT_OPEN', txLog, {
requestId: ctx.requestId,
})