Files
accounted/app/api/transactions/[id]/match-invoice/route.ts
T
17d48ebb84 fix(invoices): guard cash-method partial payments across every payment path (#1413)
* fix(invoices): guard cash-method partial payments across every payment path

A never-booked kontantmetoden invoice can only be settled by the generated
cash entry (createInvoiceCashEntry / createSupplierInvoiceCashEntry), and
that entry always books the FULL invoice: it takes no payment amount. Three
payment surfaces still let partial payments through to it, corrupting books:

- settleInvoicePayment dropped the fully-paid term entirely, so a partial
  payment (Stripe sync, mark-paid) booked the entire invoice: over-recognized
  revenue, over-declared output VAT, and a bank debit that did not match the
  money received.
- The dashboard and agent match-transaction paths fell back to an
  accrual-style clearing entry against an EMPTY 1510: negative receivable,
  no revenue, no moms (ML 13 kap 8 § puts each installment's moms in its own
  receipt period). The comment claimed the credit "gets resolved on final
  payment", but the cash builder never touches 1510 and books the full
  total, so the final payment double-debited the bank instead.
- The supplier routes had no full-settlement term at all, so a partial
  payment booked the full expense + input VAT.

Fix: one shared predicate (cashPartialBlockReason in booking-mode.ts)
rejects generated cash entries unless the payment settles the invoice in
full from a fully unpaid state, wired into all six POST surfaces, the agent
commit paths, and the three preview routes (so dialogs cannot propose a
verifikat the POST refuses). New bilingual error codes
INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED / SI_CASH_PARTIAL_UNSUPPORTED.
Invoices booked at issue are unaffected: their partial payments keep the
normal 1510/2440 clearing path.

The v1 match-invoice route already had this guard (VALIDATION_ERROR); its
behavior is unchanged. Proper per-installment recognition (proportional
revenue + moms per receipt) is the follow-up feature that would lift this
restriction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(review): drop stale ML 13 kap 8 § cites for kontantmetoden VAT timing

Compliance-review finding: the section is the old ML 1994:200 numbering; in
ML 2023:200, 13 kap covers input-VAT deduction, not redovisningstidpunkt.
The substantive rule (bokslutsmetoden reports moms at payment, per
installment, except at year-end) is unchanged and stated without a section
cite until the current-law section is verified. Comments and cookbook prose
only; no behavior change. Also fixes the two pre-existing occurrences.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 18:05:11 +02:00

801 lines
35 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,
})
}
// 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) {
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)
}
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',
})
.eq('id', transactionId)
if (updateTxError) {
txLog.error('failed to link transaction to invoice', updateTxError)
return errorResponseFromCode('MATCH_INVOICE_LINK_TX_FAILED', txLog, { requestId })
}
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 },
)