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
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user