Files
accounted/app/api/transactions/[id]/match-invoice/route.ts
T
MattssonandClaude Fable 5 ced6f1c65b fix(reconciliation): await the pre-existing matched/storno match-log writes (#1606)
Final Swedish-review finding (approved by Emil): the six fire-and-forget
logMatchEvent calls that predate this branch in the four match routes are
now awaited, matching the rest of the PR and the DECISIONS claim that
every audit write is awaited. logMatchEvent never throws; on serverless
an unawaited promise can be frozen when the response returns, silently
dropping the behandlingshistorik row (BFNAR 2013:2 kap 8).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:35:14 +02:00

839 lines
37 KiB
TypeScript

import { NextResponse } from 'next/server'
import { cashPartialBlockReason } from '@/lib/bookkeeping/booking-mode'
import { createInvoiceCashEntry } from '@/lib/bookkeeping/invoice-entries'
import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, 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'
import { MatchInvoiceSchema } from '@/lib/api/schemas'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection'
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
import { paidAtFromDate } from '@/lib/invoices/paid-at'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { Currency, EntityType, Invoice, Transaction } from '@/types'
ensureInitialized()
/**
* POST /api/transactions/[id]/match-invoice
*
* Confirms an invoice match for a transaction. Supports partial payments:
* 1. If transaction has an auto-categorization journal entry, storno it first
* 2. Links transaction to invoice (sets invoice_id)
* 3. Updates invoice status to 'paid' or 'partially_paid'
* 4. Records payment in invoice_payments table
* 5. Creates journal entry for payment receipt
* - Debit <resolved bank account> Företagskonto (Bank)
* - Credit 1510 Kundfordringar (Accounts Receivable)
*
* The bank leg is resolved from THIS transaction's own cash_account_id via
* resolveSettlementAccount (cash_account_id -> cash_accounts.ledger_account),
* never hardcoded to 1930: a receipt landing in a secondary SEK account or a
* foreign-currency account (e.g. 1940 for EUR) must book to that account, not
* silently to the primary bank account. Mirrors the fix already applied on
* the supplier-invoice side (match-supplier-invoice/route.ts), which resolves
* the credited account the same way instead of falling back to a stale
* company-wide setting.
*/
export const POST = withRouteContext(
'transaction.match_invoice',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id: transactionId } = await params
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, MatchInvoiceSchema, {
log,
operation: 'transaction.match_invoice',
})
if (!validation.success) return validation.response
const { invoice_id, force, expected_journal_entry_id, lines: customLines } = validation.data
const txLog = log.child({ transactionId, invoiceId: invoice_id })
const { data: transaction, error: fetchTxError } = await supabase
.from('transactions')
.select('*')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (fetchTxError || !transaction) {
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, { requestId })
}
if (transaction.amount <= 0) {
return errorResponseFromCode('MATCH_INVOICE_NOT_INCOME', txLog, {
requestId,
details: { amount: transaction.amount },
})
}
if (transaction.invoice_id) {
return errorResponseFromCode('MATCH_INVOICE_TX_ALREADY_LINKED', txLog, {
requestId,
details: { existingInvoiceId: transaction.invoice_id },
})
}
const { data: invoice, error: fetchInvError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*), credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
.eq('id', invoice_id)
.eq('company_id', companyId)
.single()
if (fetchInvError || !invoice) {
return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', txLog, { requestId })
}
// Defense-in-depth: the InvoicePicker UI filters proformas / delivery
// notes out of the candidate list, but a direct API call could still
// pass a proforma id. A proforma is not a faktura per ML 17 kap 24§:
// no VAT obligation, no binding payment: so matching one against a
// bank receipt would book income and VAT incorrectly.
const docType = (invoice as { document_type?: string }).document_type ?? 'invoice'
if (docType !== 'invoice') {
return errorResponseFromCode('MATCH_INVOICE_NOT_INVOICE_TYPE', txLog, {
requestId,
details: { documentType: docType },
})
}
if (invoice.credited_invoice_id) {
return errorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, { requestId })
}
const activeCreditNotes = ((invoice as { credit_notes?: Array<{
status: string
creation_complete?: boolean
}> }).credit_notes ?? []).filter(
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
)
if (activeCreditNotes.length > 0) {
return errorResponseFromCode('MATCH_INVOICE_CREDIT_NOTE', txLog, {
requestId,
details: { reason: 'active_credit_note' },
})
}
if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') {
return errorResponseFromCode('MATCH_INVOICE_NOT_OPEN', txLog, {
requestId,
details: { currentStatus: invoice.status },
})
}
// Cross-currency settlement (replaces the PR #614 round-9 block).
//
// invoices.paid_amount / remaining_amount are denominated in
// invoice.currency; invoice_payments rows carry currency =
// invoice.currency with amount in that currency. When tx and invoice
// currencies differ, we convert tx.amount (SEK) to invoice currency
// using the Riksbanken spot rate on the payment date (ML 8 kap 21-23§)
// and accumulate / record in invoice currency throughout. The JE-lines
// helper gets the same converted amount so the verifikat balances
// exactly (FX-diff posted to 3960/7960). A manual rate may be supplied
// via the request body when the lookup fails (e.g. bank-statement rate
// when Riksbanken hasn't published for the date yet).
type FxConversion =
| { required: false }
| {
required: true
rate: number
rate_date: string
paidInInvoiceCurrency: number
// Provenance of the rate actually used, recorded for the audit
// trail: 'manual' = caller-supplied from a bank statement (Riksbanken
// had no rate for the date), 'riksbanken' = spot rate fetched on the
// payment date. A manual override on a money path must be traceable
// (BFL 5 kap 6-7§; ML 8 kap 21-23§).
source: 'manual' | 'riksbanken'
}
// A bank row is stored in ITS OWN currency: transactions.amount is
// denominated in transactions.currency, and the SEK value lives either in
// amount_sek (pre-computed at ingest) or is derivable from exchange_rate.
// Every journal entry line is SEK, so a foreign row whose SEK value cannot
// be established must not be booked or allocated at all: substituting the
// raw foreign number would settle a 500 USD receipt as 500 SEK, a tenth of
// the real payment. Rows in exactly that shape exist: when the Riksbanken
// lookup fails at ingest the transaction is written with neither field
// (lib/transactions/ingest.ts). Refuse loudly, the same way the
// match_batch_allocate RPC refuses with BATCH_FX_RATE_MISSING, instead of
// guessing a rate of 1.
const txIsForeign = !!transaction.currency && transaction.currency !== 'SEK'
if (
txIsForeign &&
transaction.amount_sek == null &&
!(transaction.exchange_rate != null && transaction.exchange_rate > 0)
) {
return errorResponseFromCode('MATCH_INVOICE_TX_FX_RATE_MISSING', txLog, {
requestId,
details: {
transactionCurrency: transaction.currency,
transactionDate: transaction.date,
},
})
}
// The actual SEK that hit the bank. Resolved through the SAME helper
// buildInvoicePaymentClearingLines uses for the bank leg (amount_sek first,
// then amount * exchange_rate), so the FX conversion below and the posted
// verifikat can never disagree on the SEK figure. SEK rows return
// Math.abs(amount) unchanged.
const txAbsSek =
Math.round(
resolveSekAmount(
Math.abs(transaction.amount),
transaction.amount_sek != null ? Math.abs(transaction.amount_sek) : null,
transaction.currency,
transaction.exchange_rate,
) * 100,
) / 100
let fx: FxConversion = { required: false }
if (transaction.currency !== invoice.currency) {
const manualRate =
typeof validation.data?.manual_exchange_rate === 'number' &&
validation.data.manual_exchange_rate > 0
? validation.data.manual_exchange_rate
: null
let rate = manualRate
let rateDate = transaction.date
if (rate == null) {
const rateInfo = await fetchExchangeRate(
invoice.currency as Currency,
new Date(transaction.date),
)
if (rateInfo && rateInfo.rate > 0) {
rate = rateInfo.rate
rateDate = rateInfo.date
}
}
if (rate == null || rate <= 0) {
return errorResponseFromCode('MATCH_INVOICE_FX_RATE_UNAVAILABLE', txLog, {
requestId,
details: {
transactionCurrency: transaction.currency,
invoiceCurrency: invoice.currency,
paymentDate: transaction.date,
},
})
}
const paidInInvoiceCurrency = Math.round((txAbsSek / rate) * 10000) / 10000
fx = {
required: true,
rate,
rate_date: rateDate,
paidInInvoiceCurrency,
source: manualRate != null ? 'manual' : 'riksbanken',
}
}
// Hard-duplicate guard: if the invoice is 'sent'/'overdue' but already
// has a payment voucher attached (status leak), refuse: booking again
// would double-credit 1510 / double-debit 1930. Partially-paid invoices
// pass through; additional payments are legitimate.
if (invoice.status === 'sent' || invoice.status === 'overdue') {
const { data: existingPayments } = await supabase
.from('invoice_payments')
.select('journal_entry_id')
.eq('company_id', companyId)
.eq('invoice_id', invoice_id)
.not('journal_entry_id', 'is', null)
.limit(1)
if (existingPayments && existingPayments.length > 0) {
return errorResponseFromCode('MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER', txLog, {
requestId,
details: {
existing_journal_entry_id: (existingPayments[0] as { journal_entry_id: string }).journal_entry_id,
},
})
}
}
// Soft-duplicate guard: scan for a manual verifikation that already
// books this bank receipt outside the invoice flow. The customer's
// exact case: they posted Dr 1930 / Cr 3100 by hand; the matcher
// would otherwise create a second voucher and double-book. Bypassed
// with force=true after the user reviews the candidate in the UI.
//
// force=true is bound to a specific candidate via expected_journal_entry_id
// (validated by the schema). We re-detect the candidate server-side and
// refuse the bypass if it no longer matches: a stale or fabricated
// expected id cannot wave the guard away. The pre-flight runs even when
// a candidate is detected so the audit log records the verifikation the
// user opted to dismiss.
let dismissedCandidateId: string | null = null
try {
const candidate = await detectDuplicatePaymentVoucher(supabase, {
companyId: companyId!,
transactionId,
transactionDate: transaction.date,
transactionAmount: transaction.amount,
// `amount` is in `currency`; the 19xx legs the detector compares it
// against are always SEK. Selected above via select('*').
transactionCurrency: transaction.currency ?? null,
transactionAmountSek: transaction.amount_sek ?? null,
transactionExchangeRate: transaction.exchange_rate ?? null,
})
if (!force) {
if (candidate) {
return errorResponseFromCode('MATCH_INVOICE_POSSIBLE_DUPLICATE', txLog, {
requestId,
details: { candidate },
})
}
} else {
if (!candidate || candidate.journal_entry_id !== expected_journal_entry_id) {
// Either no current duplicate (force is moot: caller should retry
// without force) or the candidate the caller claims to have seen
// doesn't match what we detect now. Reject so an automation can't
// smuggle force=true past the guard with a guessed id.
return errorResponseFromCode('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH', txLog, {
requestId,
details: {
expected_journal_entry_id,
detected_journal_entry_id: candidate?.journal_entry_id ?? null,
},
})
}
dismissedCandidateId = candidate.journal_entry_id
}
} catch (err) {
// Detection failure must not block the non-force match: log and
// continue. force=true requires a successful detection, so re-throw
// its branch as a clean 500 via the wrapper.
if (force) {
txLog.error('duplicate-payment-voucher detection failed under force=true', err as Error)
return errorResponse(err, txLog, { requestId })
}
txLog.warn('duplicate-payment-voucher detection failed (continuing)', err as Error)
}
if (force && dismissedCandidateId) {
txLog.warn('soft-duplicate guard bypassed', {
reason: 'force=true',
requestId,
transactionId,
invoiceId: invoice_id,
userId: user.id,
// The verifikation the user reviewed and dismissed. Recorded so the
// override can be traced back to the specific duplicate that was
// surfaced in the pre-flight UI.
dismissedJournalEntryId: dismissedCandidateId,
})
}
// A RECONCILIATION link (reconciliation_method set) is not a conflicting
// booking: the entry it points at is an independent verifikat (SIE import,
// salary run, manual booking) that may evidence OTHER affärshändelser, and
// reversing it wholesale as a side effect of matching one payment would be
// an over-broad rättelse (BFL 5 kap 5 §: a correction is scoped to the
// actual error). Nothing is detached HERE: the final transaction update
// below overwrites the pointer and clears reconciliation_method in the
// same write, so a failure anywhere in between leaves the existing link
// fully intact instead of orphaning the row.
const priorReconciliationLink =
transaction.journal_entry_id && transaction.reconciliation_method
? {
journalEntryId: transaction.journal_entry_id as string,
method: transaction.reconciliation_method as string,
}
: null
// Storno conflicting auto-categorization JE before any other state change.
// If storno fails, return immediately: nothing else has been modified.
if (transaction.journal_entry_id && !priorReconciliationLink) {
try {
await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id)
const { error: clearJeError } = await supabase
.from('transactions')
.update({ journal_entry_id: null })
.eq('id', transactionId)
if (clearJeError) {
txLog.warn('failed to clear journal_entry_id after storno', clearJeError)
}
await logMatchEvent(supabase, user.id, transactionId, 'storno_conflict_resolved', {
invoiceId: invoice_id,
previousState: { journal_entry_id: transaction.journal_entry_id },
newState: { journal_entry_id: null },
})
} catch (err) {
txLog.error('failed to storno conflicting journal entry', err as Error)
return errorResponse(err, txLog, { requestId })
}
}
// paidAmountInInvoiceCurrency is what gets accumulated into
// invoice.paid_amount / remaining_amount and stored on the
// invoice_payments row. For same-currency it's just tx.amount; for
// cross-currency it's the Riksbanken-rate conversion computed above.
// Using SEK directly for a USD invoice would corrupt the column units
// (the bug the PR #614 round-9 block was working around).
const paidAmountInInvoiceCurrency = fx.required
? fx.paidInInvoiceCurrency
: transaction.amount
// Overshoot guard + paid/remaining math: shared with the v1 and agent
// (commit) paths via planInvoicePayment so they cannot drift again. Runs
// before any JE is created, so a doomed match never burns a voucher number.
// Pure-SEK settlements absorb sub-krona öresavrundning (booked to 3740 by
// buildInvoicePaymentClearingLines) so a whole-krona payment settles in full.
const pureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK'
const payment = planInvoicePayment(invoice, paidAmountInInvoiceCurrency, {
absorbOreRounding: pureSek,
})
if (!payment.ok) {
return errorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', txLog, {
requestId,
details: payment.details,
})
}
const { newPaidAmount, newRemaining, isFullyPaid, newStatus } = payment.plan
const paidAt = isFullyPaid ? paidAtFromDate(transaction.date) : null
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Debit the cash account THIS transaction actually belongs to, never a
// hardcoded 1930: cash_account_id -> cash_accounts.ledger_account is the
// only source of truth for which bank/cash account a real, matched
// transaction settled into. A receipt into a secondary SEK account or a
// foreign-currency account (e.g. 1940 for EUR) must book there, not to
// the primary account, or the GL silently diverges from the actual bank
// statement it's meant to represent (BFL 5 kap 1-2§).
const paymentAccount = await resolveSettlementAccount(
supabase,
companyId!,
transaction.cash_account_id,
txLog,
)
// Drive the JE shape from the INVOICE'S booking state, not from the
// company's current accounting_method setting. If the invoice was already
// booked at send (Dr 1510 / Cr 30xx + VAT) we MUST clear 1510 here:
// otherwise the receivable stays orphaned and 30xx + VAT get double-
// counted. This happens when a company sent invoices under accrual,
// then flipped to kontantmetoden before payment arrived.
// Only when the invoice carries no prior JE (pure kontantmetoden, no
// receivable on the books) do we recognise revenue + VAT here.
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
// Reject cash-method partial payments and part-paid completions for pure
// kontantmetoden invoices (no prior JE), mirroring the v1 route. The old
// partial fallback booked an accrual-style clearing entry against an
// EMPTY 1510 (negative receivable, no revenue, no moms: bokslutsmetoden
// reports moms at payment, per installment), and the
// "resolved on final payment" theory was wrong: createInvoiceCashEntry
// never touches 1510 and books the FULL total, so the final payment
// double-debited the bank account instead. When the invoice was already
// booked under accrual, the clearing entry IS the correct partial path
// regardless of the company's current setting.
const cashBlock = cashPartialBlockReason({
invoiceAlreadyBooked,
accountingMethod,
priorPaidAmount: (invoice as { paid_amount?: number | null }).paid_amount,
paysRemainingInFull: isFullyPaid,
})
if (cashBlock) {
return errorResponseFromCode('INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED', txLog, {
requestId,
details: {
reason: cashBlock,
payment_amount: paidAmountInInvoiceCurrency,
invoice_total: invoice.total,
},
})
}
let journalEntryId: string | null = null
try {
if (customLines) {
// User-edited rows from the match dialog. Validate balance, then
// post via createJournalEntry directly. source_type still derives
// from the routing decision so downstream payment-sync (which keys
// off invoice_paid / invoice_cash_payment) keeps working.
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return errorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', txLog, {
requestId,
details: { totalDebit, totalCredit },
})
}
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, transaction.date)
if (!fiscalPeriodId) {
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
requestId,
details: { paymentDate: transaction.date },
})
}
const sourceType = useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
const desc = invoice.customer?.name
? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}`
: `Inbetalning kundfaktura ${invoice.invoice_number}`
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: desc,
source_type: sourceType,
source_id: invoice.id,
lines: customLines,
})
journalEntryId = journalEntry?.id ?? null
} else if (useCashEntry) {
const journalEntry = await createInvoiceCashEntry(
supabase, companyId, user.id, invoice as Invoice, transaction.date,
entityType, invoice.customer?.name, paymentAccount,
)
journalEntryId = journalEntry?.id ?? null
} else {
// Clearing entry against 1510. Covers accrual and cash-with-prior-JE
// (mid-stream method switch). Pure kontantmetoden partials never
// reach this branch: they are rejected above, because 1510 has no
// prior balance to clear and the cash builder cannot book a partial.
//
// Builds lines via buildInvoicePaymentClearingLines so the verifikat
// is byte-identical to what the preview route showed the user. For
// same-currency invoices that's just 1930/1510. For cross-currency
// it also posts a 3960/7960 FX-diff line so the verifikat balances
// per BFL 5 kap 4-5§. Bypasses createInvoicePaymentJournalEntry on
// this single path (mark-paid and other callers still use it):
// see lib/bookkeeping/invoice-payment-lines.ts for the contract.
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, transaction.date)
if (!fiscalPeriodId) {
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
requestId,
details: { paymentDate: transaction.date },
})
}
const desc = invoice.customer?.name
? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}`
: `Inbetalning kundfaktura ${invoice.invoice_number}`
const { lines: clearingLines } = buildInvoicePaymentClearingLines(
{
amount: transaction.amount,
amount_sek: transaction.amount_sek ?? null,
currency: transaction.currency,
exchange_rate: transaction.exchange_rate ?? null,
},
{
currency: invoice.currency,
exchange_rate: invoice.exchange_rate ?? null,
remaining_amount: invoice.remaining_amount ?? null,
total: invoice.total,
paid_amount: invoice.paid_amount ?? null,
},
desc,
// Cross-currency: pass the spot-rate-converted invoice-currency
// amount so the helper credits 1510 proportionally and posts the
// FX-diff line. Same-currency: undefined, helper just uses bankSek.
fx.required ? fx.paidInInvoiceCurrency : undefined,
paymentAccount,
)
// Re-propagate the invoice's default dimension bag onto every leg,
// including the FX result lines, so a project's kursvinst/kursförlust
// stays inside the project P&L. createInvoicePaymentJournalEntry does
// this for its own callers; the shared line-builder is dimension-
// agnostic, so the two routes that use it have to do it themselves or
// dimension users silently lose the tagging on payment vouchers.
// Copied per line: a shared object would let one line's mutation leak.
const defaultDimensions = coerceDimensionsBag(
(invoice as { default_dimensions?: unknown }).default_dimensions,
)
if (defaultDimensions) {
for (const line of clearingLines) line.dimensions = { ...defaultDimensions }
}
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: desc,
source_type: 'invoice_paid',
source_id: invoice.id,
lines: clearingLines,
})
journalEntryId = journalEntry?.id ?? null
}
} catch (err) {
// AccountsNotInChart is fatal so the UI can open the activation dialog.
if (err instanceof AccountsNotInChartError) {
return errorResponse(err, txLog, { requestId })
}
// A foreign-currency invoice with no booking rate is a missing-input
// failure, not a transient booking failure: buildInvoicePaymentClearing
// Lines refuses rather than valuing the 1510 credit at a fabricated rate.
// Fully retryable once invoice.exchange_rate is on file.
// Dispatch on `code`, not instanceof: the class lives in a module route
// tests routinely vi.mock away, and the literal keeps a mocked-away
// export from turning into an `undefined === undefined` catch-all.
if ((err as { code?: unknown })?.code === 'MATCH_INVOICE_BOOKING_RATE_MISSING') {
return errorResponse(err, txLog, { requestId })
}
txLog.error('failed to create payment journal entry', err as Error)
// ANY failed payment voucher fails the whole match (mirrors
// match-supplier-invoice): proceeding used to mark the invoice paid and
// link the transaction with NO verifikat, an unrecoverable half-state:
// mark-paid rejects 'paid' invoices and this route rejects linked
// transactions, so no flow could ever complete the booking afterwards.
// Typed bookkeeping errors (period locked, no fiscal period, ...) map
// to their registered envelope; everything else returns the invoice-
// side payment-failure code with a Swedish reason via getErrorMessage,
// so the raw message never reaches the user (issue #337).
if (isBookkeepingError(err)) {
return errorResponse(err, txLog, { requestId })
}
return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, {
requestId,
details: { reason: getErrorMessage(err, { context: 'invoice' }) },
})
}
if (!journalEntryId) {
// createJournalEntry resolved without an id: the same unrecoverable
// half-state as a thrown failure, so the match aborts here too
// (mirrors the supplier route's !journalEntryId guard).
return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, { requestId })
}
// Underlag for the payment verifikation: re-attach the invoice PDF that
// was archived on send to the new payment journal entry. document_
// attachments.journal_entry_id is one-to-one, so we insert a parallel
// row pointing at the same storage_path. Same WORM file, second JE
// pointer: no copy, no schema change. Non-blocking (BFL 7 kap audit
// gap, but the bank line + invoice still exist as evidence).
if (journalEntryId && invoice.journal_entry_id) {
try {
const { data: invoiceDoc } = await supabase
.from('document_attachments')
.select('storage_path, file_name, file_size_bytes, mime_type, sha256_hash')
.eq('journal_entry_id', invoice.journal_entry_id)
.eq('company_id', companyId)
.eq('is_current_version', true)
.limit(1)
.maybeSingle()
if (invoiceDoc) {
// Destructure error: Supabase client returns { data, error } on
// postgres-level failures (unique constraint, RLS reject) instead
// of throwing, so the surrounding try/catch only covers thrown
// JS exceptions. Log via warn so attachment failures are visible
// in logs even though we don't abort the match.
const { error: attachErr } = await supabase.from('document_attachments').insert({
user_id: user.id,
company_id: companyId,
uploaded_by: user.id,
upload_source: 'system',
storage_path: invoiceDoc.storage_path,
file_name: invoiceDoc.file_name,
file_size_bytes: invoiceDoc.file_size_bytes,
mime_type: invoiceDoc.mime_type,
sha256_hash: invoiceDoc.sha256_hash,
journal_entry_id: journalEntryId,
})
if (attachErr) {
txLog.warn('failed to attach invoice PDF to payment journal entry', {
attachError: attachErr.message,
paymentJournalEntryId: journalEntryId,
invoiceJournalEntryId: invoice.journal_entry_id,
})
}
}
} catch (err) {
txLog.warn('failed to attach invoice PDF to payment journal entry', err as Error)
}
}
// Optimistic lock: only update if invoice is still in a matchable state.
const { data: updatedRows, error: updateInvError } = await supabase
.from('invoices')
.update({
status: newStatus,
paid_at: paidAt,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
})
.eq('id', invoice_id)
.in('status', ['sent', 'overdue', 'partially_paid'])
.select('id')
if (updateInvError) {
txLog.error('failed to update invoice status', updateInvError)
return errorResponse(updateInvError, txLog, { requestId })
}
if (!updatedRows || updatedRows.length === 0) {
return errorResponseFromCode('MATCH_INVOICE_ALREADY_PAID', txLog, { requestId })
}
// No cash-method note anymore: pure kontantmetoden partials are rejected
// above, and for an invoice booked at send the clearing entry handles a
// partial correctly, so the note would be misleading.
// Provenance for a manually-supplied FX rate. The Riksbanken spot rate is
// self-documenting (rate + rate_date are reproducible), but a rate the
// user typed from their bank statement is an override of the ML 8 kap
// 21-23§ obligation and must leave a trail on the verifikat's payment row
// (BFL 5 kap 6-7§: the verifikation must reflect the actual affärshändelse).
const manualRateNote =
fx.required && fx.source === 'manual'
? `Manuell valutakurs ${fx.rate} ${invoice.currency}/SEK (betalningsdatum ${transaction.date})`
: null
const paymentNotes = manualRateNote
// Payment row stores amount in INVOICE currency (the column unit). For
// same-currency that's tx.amount; for cross-currency it's the spot-rate
// conversion above. exchange_rate records the rate ACTUALLY USED for
// this payment: Riksbanken (or manual override) on tx.date: per
// ML 8 kap 21-23§. Falling back to invoice.exchange_rate would record
// the invoice-date rate, which is what the round-7/8 bot reviews
// explicitly flagged as wrong.
const { error: paymentInsertError } = await supabase
.from('invoice_payments')
.insert({
user_id: user.id,
company_id: companyId,
invoice_id,
payment_date: transaction.date,
amount: paidAmountInInvoiceCurrency,
currency: invoice.currency,
exchange_rate: fx.required ? fx.rate : invoice.exchange_rate,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
notes: paymentNotes,
})
if (paymentInsertError) {
if (paymentInsertError.code === '23505') {
return errorResponseFromCode('MATCH_INVOICE_DUPLICATE_PAYMENT', txLog, { requestId })
}
txLog.error('failed to record invoice payment', paymentInsertError)
return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, { requestId })
}
// The invoice is now settled, so every OTHER transaction still carrying a
// suggestion pointer at it is dead: retire them (issue #1259). This
// request's own row is cleared by the update just below.
if (isFullyPaid) {
await clearSettledInvoiceSuggestions(supabase, companyId!, 'invoice', invoice_id, {
exceptTransactionId: transactionId,
})
}
const { error: updateTxError } = await supabase
.from('transactions')
.update({
invoice_id: invoice_id,
potential_invoice_id: null,
journal_entry_id: journalEntryId,
is_business: true,
category: 'income_services',
// The invoice match supersedes any prior reconciliation link: the
// stale method label must not survive the re-pointed journal_entry_id
// (deferred detach, see the priorReconciliationLink block above).
// Unconditional literal on purpose: null is already the value on every
// non-reconciliation-linked row, and a literal payload keeps the
// phantom-column scanner able to verify the column set.
reconciliation_method: null,
})
.eq('id', transactionId)
if (updateTxError) {
txLog.error('failed to link transaction to invoice', updateTxError)
return errorResponseFromCode('MATCH_INVOICE_LINK_TX_FAILED', txLog, { requestId })
}
// The deferred detach committed with the update above: record the release
// of the prior reconciliation link so the append-only trail shows the full
// transition (behandlingshistorik, BFNAR 2013:2 kap 8).
if (priorReconciliationLink) {
await logMatchEvent(supabase, user.id, transactionId, 'unmatched', {
invoiceId: invoice_id,
previousState: {
journal_entry_id: priorReconciliationLink.journalEntryId,
reconciliation_method: priorReconciliationLink.method,
},
newState: { journal_entry_id: journalEntryId, reconciliation_method: null },
})
}
await logMatchEvent(supabase, user.id, transactionId, 'matched', {
invoiceId: invoice_id,
matchConfidence: 1.0,
matchMethod: 'manual_confirm',
// rate_source / exchange_rate live inside new_state (the persisted JSON
// column) so a manual override: a user-supplied money-path input: is
// distinguishable from an automatic Riksbanken lookup in the audit trail
// (swarm V16 / SOC 2 CC6.1 / GDPR Art.5(1)(f)). Same-currency matches
// carry rate_source: null.
newState: {
status: newStatus,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
rate_source: fx.required ? fx.source : null,
exchange_rate: fx.required ? fx.rate : null,
},
})
try {
eventBus.emit({
type: 'invoice.match_confirmed',
payload: {
invoice: {
...invoice,
status: newStatus,
paid_at: paidAt,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
} as Invoice,
transaction: {
...transaction,
invoice_id,
potential_invoice_id: null,
journal_entry_id: journalEntryId,
is_business: true,
category: 'income_services',
} as Transaction,
userId: user.id,
companyId,
},
})
} catch (err) {
txLog.warn('invoice.match_confirmed event emission failed', err as Error)
}
return NextResponse.json({
success: true,
invoice_status: newStatus,
paid_at: paidAt,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
journal_entry_id: journalEntryId,
// Always null since a failed voucher now aborts the whole match; the
// field survives for response-shape compatibility with existing callers.
journal_entry_error: null,
category: 'income_services',
})
},
{ requireWrite: true },
)