Files
accounted/app/api/transactions/[id]/match-invoice/preview/route.ts
T
Jonas FlodénandJakob Wennberg 64ea0fef02 fix(transactions): resolve customer-invoice payment account from cash_account_id (#987)
* refactor(transactions): add shared settlement-account resolution helper

Cherry-picked from fork/worktree-starry-waddling-wirth (PR #985) commit
34d5d35 — pulling in just the new lib/bookkeeping/settlement-account.ts
helper and its test, without the match-supplier-invoice route changes
from that PR (those depend on 8bfc31d, not yet on main, and are out of
scope here).

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(transactions): resolve customer-invoice payment account from cash_account_id

Customer-invoice payment matching never resolved the bank leg from the
matched transaction's own cash_account_id: it was unconditionally
hardcoded to 1930 in buildInvoicePaymentClearingLines,
createInvoicePaymentJournalEntry, and createInvoiceCashEntry, with no
override parameter at all. Any bank receipt landing in a non-primary
cash/bank account (a secondary SEK account, or a foreign-currency
account like 1940 for EUR) was silently misbooked to 1930 -- the same
class of bug PR #985 fixed on the supplier-invoice side, except
unconditional there (no stale-setting trigger needed).

Adds an optional paymentAccount parameter (default '1930', preserving
behavior for every caller that doesn't pass one) to the three lib
functions, and threads resolveSettlementAccount(cash_account_id) through
every real bank-transaction-matching call site: the dashboard
match-invoice route (POST + preview), its v1/MCP-facing counterpart, and
the agent/MCP match_transaction_invoice commit path. Deliberately left
on default 1930: mark-paid (dashboard + v1, no bank transaction in
scope), fix-cash-mismatch (narrow historical repair tool for a different
bug), and the agent mark_invoice_paid commit path.

Brings in lib/bookkeeping/settlement-account.ts (cherry-picked from
fork/worktree-starry-waddling-wirth commit 34d5d35) so this PR is
mergeable independently of #985's merge order.

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* test(invoice-entries): cover ROT/RUT 1513 line stays fixed under a non-default paymentAccount

Compliance-bot finding on PR #987: createInvoiceCashEntry's paymentAccount
override was only tested against a plain standard_25 invoice, never
combined with a ROT/RUT deduction_type item. The 1513 receivable line was
already correctly untouched by paymentAccount (it's never the bank leg),
this just closes the test-coverage gap.

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(bookkeeping): abort instead of silently defaulting to 1930 when settlement-account lookup errors

Same shared-helper fix as PR #985/#986: resolveSettlementAccount now
throws BookkeepingDatabaseError on a genuine cash_accounts query error
instead of warning and falling back to 1930. An explicit cash_account_id
almost certainly resolves to a non-1930 account, so a transient failure
masking it risked the same class of misbooking this whole PR series
exists to fix, just via infra flakiness instead of a stale setting.

No route/commit.ts changes needed: match-invoice (POST + preview) run
under withRouteContext's existing catch-all, and commitPendingOperation
already has identical generic bookkeeping-error handling for every other
engine failure. Added regression tests for all three call sites
(dashboard POST, preview, and the agent/MCP commit path) confirming the
abort rather than assuming the shared infrastructure handles it silently.

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(v1): guard resolved settlement account against chart of accounts

Closes the two remaining gaps from jakobwennberg's triage on #987
(after rebasing onto main and picking up the already-pushed
resolveSettlementAccount abort-on-error fix):

- Added the v1 match-invoice route-level test coverage that was
  missing (cash-account threading, BOOKKEEPING_DATABASE_ERROR abort,
  ACCOUNTS_NOT_IN_CHART), mirroring the dashboard route's existing
  settlement-account-resolution tests.
- Added the same findUnresolvableAccounts pre-validation guard against
  chart_of_accounts that 32c07c4 added to #986's match-supplier-invoice
  route, gated on !customLines since that is the only branch here that
  consumes the resolved paymentAccount.

Signed-off-by: Jonas Flodén

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* test(bookkeeping): align settlement-account error assertion with #985

Use .rejects.toBeInstanceOf(BookkeepingDatabaseError) instead of
toMatchObject({ constructor: ... }), matching #985's edef79d follow-up
(the assertion was correct either way, but this is the more idiomatic
check and now makes the shared helper's test file byte-identical
across #985/#986/#987, removing the add/add merge conflict between
them noted in the merge-order validation.

Signed-off-by: Jonas Flodén

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* test(invoice-payment-lines): add missing 3740 coverage for non-1930 paymentAccount

CodeRabbit nitpick on #987: the test named "...does not affect the
FX-diff or öresavrundning lines" only exercised the 3960 FX-diff
branch, never the pure-SEK 3740 öresavrundning branch it also claimed
to cover. Split into two tests: the existing one renamed to describe
only its FX-diff coverage, plus a new pure-SEK sub-krona-short case
with a resolved non-1930 paymentAccount asserting the 3740 line books
correctly and the bank leg lands on the resolved account, not 1930.

Signed-off-by: Jonas Flodén

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(ci): quote compliance-pr.yml name to fix invalid YAML

The unquoted colon in `name: compliance: review (advisory)` (introduced
by #890's em-dash removal, which swapped an em dash for a colon
in-place) makes YAML read it as a nested mapping key, so GitHub can't
parse the workflow at all - every run fails with 0 jobs scheduled.

Signed-off-by: Jonas Flodén

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* Revert "fix(ci): quote compliance-pr.yml name to fix invalid YAML"

This reverts commit e7c890245d1834cd8f3c9b13a2bc3247fea7eacb.

Signed-off-by: Jonas Flodén <jonas@floden.nu>

---------

Signed-off-by: Jonas Flodén <jonas@floden.nu>
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
2026-07-12 21:23:19 +02:00

320 lines
14 KiB
TypeScript

/**
* GET /api/transactions/[id]/match-invoice/preview?invoice_id=...
*
* Returns the journal entry lines that match-invoice would create for this
* (transaction, invoice) pair. Read-only: does not stage or write anything.
*
* The shape mirrors the routing decision in the POST handler: if the invoice
* was already booked (invoice.journal_entry_id is set, i.e. 1510 is on the
* books), we preview the clearing entry (Dr <resolved account> / Cr 1510).
* Only when the invoice was never booked AND the company is on kontantmetoden
* AND the receipt fully pays the invoice do we preview the cash entry
* (Dr <resolved account> / Cr 30xx / Cr 26xx).
*
* The bank leg is resolved from THIS transaction's own cash_account_id via
* resolveSettlementAccount, never hardcoded to 1930, so the preview stays
* byte-identical to what the POST handler commits (mirrors the fix already
* applied on the supplier-invoice side).
*
* The UI uses this to show the user the exact lines before they confirm:
* the lack of any preview was part of the reported bug.
*/
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { roundOre, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money'
import { getRevenueAccount, getOutputVatAccount } from '@/lib/bookkeeping/invoice-entries'
import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import type { Currency, EntityType, Invoice, InvoiceItem } from '@/types'
import { z } from 'zod'
type PreviewLine = {
account_number: string
debit_amount: number
credit_amount: number
description: string
}
const QuerySchema = z.object({
invoice_id: z.string().uuid(),
})
export const GET = withRouteContext(
'transaction.match_invoice_preview',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id: transactionId } = await params
const { supabase, companyId, log, requestId } = ctx
const url = new URL(request.url)
const parsed = QuerySchema.safeParse({ invoice_id: url.searchParams.get('invoice_id') })
if (!parsed.success) {
return errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
details: { field: 'invoice_id', message: 'invoice_id must be a UUID' },
})
}
const { invoice_id } = parsed.data
// Data minimization (GDPR Art.5(1)(c)): amount_sek + exchange_rate are
// pulled because buildInvoicePaymentClearingLines needs them for the
// cross-currency bank-leg math (round-7 FX fix). cash_account_id resolves
// which BAS account this bank line actually settles into, mirroring the
// POST handler's settlement-account lookup. All other columns would
// broaden the projection without serving the preview's purpose.
const { data: transaction, error: txErr } = await supabase
.from('transactions')
.select('id, date, amount, amount_sek, currency, exchange_rate, cash_account_id')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (txErr || !transaction) {
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId })
}
const { data: invoice, error: invErr } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('id', invoice_id)
.eq('company_id', companyId)
.single()
if (invErr || !invoice) {
return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', log, { requestId })
}
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: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Same resolution as the POST handler: debit the cash account this
// transaction is actually linked to, never a hardcoded 1930, so the
// preview stays byte-identical to what gets committed.
const paymentAccount = await resolveSettlementAccount(
supabase,
companyId!,
transaction.cash_account_id,
log,
)
// Cross-currency FX preview. When tx.currency !== invoice.currency we fetch
// the Riksbanken spot rate for invoice.currency on the tx date and surface
// the conversion to the dialog (the user sees the rate + invoice-currency-
// equivalent before approving). The committed verifikat uses the same
// numbers; the route POST handler re-runs the lookup so the rate at
// commit time is authoritative.
//
// This MUST run BEFORE the paid / remaining / fully-paid math below.
// invoice.remaining_amount and invoice.total are denominated in INVOICE
// currency, so a SEK bank tx has to be converted first. Computing the
// comparison from the raw SEK amount made a 1 000 SEK payment look like
// it fully cleared a 140 USD invoice (newRemaining went negative →
// isFullyPaid=true), which for a cash-method unbooked invoice previewed a
// cash entry (Dr 1930 / Cr 30xx) that the POST: which converts first:
// would never commit (it posts the clearing entry Dr 1930 / Cr 1510).
//
// Per ML 8 kap 21-23§ the rate effective on the payment date is the
// correct conversion. If the lookup fails (Riksbanken outage, missing
// rate for that date), the response carries `fx_conversion.error` and
// the dialog can surface a manual-rate input field instead.
type FxConversion =
| {
required: true
tx_currency: string
invoice_currency: string
rate: number
rate_date: string
paid_in_invoice_currency: number
}
| { required: true; error: 'rate_unavailable'; tx_currency: string; invoice_currency: string }
| { required: false }
let fxConversion: FxConversion = { required: false }
if (transaction.currency !== invoice.currency) {
const rateInfo = await fetchExchangeRate(
invoice.currency as Currency,
new Date(transaction.date),
)
if (rateInfo && rateInfo.rate > 0) {
// bankSek / rate = how many units of invoice.currency this payment
// satisfies. Round to 4 decimal places to preserve precision through
// subsequent partial-payment accumulations.
const txAbsSek =
transaction.currency === 'SEK'
? Math.abs(transaction.amount)
: Math.abs(transaction.amount) * (transaction.exchange_rate ?? 1)
const paidInInvoiceCurrency =
Math.round((txAbsSek / rateInfo.rate) * 10000) / 10000
fxConversion = {
required: true,
tx_currency: transaction.currency,
invoice_currency: invoice.currency,
rate: rateInfo.rate,
rate_date: rateInfo.date,
paid_in_invoice_currency: paidInInvoiceCurrency,
}
} else {
fxConversion = {
required: true,
error: 'rate_unavailable',
tx_currency: transaction.currency,
invoice_currency: invoice.currency,
}
}
}
// paidAmount is denominated in INVOICE currency so the remaining /
// fully-paid comparison is like-with-like. Same-currency → tx.amount.
// Cross-currency with a resolved rate → the spot-rate conversion (mirrors
// the POST handler's paidAmountInInvoiceCurrency).
const paidAmount =
fxConversion.required && !('error' in fxConversion)
? fxConversion.paid_in_invoice_currency
: transaction.amount
const currentRemaining =
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
const newRemaining = Math.max(
0,
Math.round((currentRemaining - paidAmount) * 100) / 100,
)
// A rate-unavailable cross-currency payment can't be resolved to invoice
// currency yet, so never report fully-paid (or preview the cash shape) on
// a guess: the dialog blocks confirm until a manual rate is entered and
// the POST recomputes the real figure.
const fxRateUnavailable = fxConversion.required && 'error' in fxConversion
// Pure-SEK whole-krona settlements absorb a sub-krona remainder as
// öresavrundning (3740) and settle in full: mirror that here so the
// preview's fully-paid signal matches the committed verifikat.
const pureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK'
const isFullyPaid =
!fxRateUnavailable &&
(newRemaining <= 0 || (pureSek && newRemaining < ORE_ROUNDING_SETTLEMENT_MAX))
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
const lines: PreviewLine[] = []
let entryType: 'clearing' | 'cash' = 'clearing'
if (useCashEntry) {
entryType = 'cash'
// Mirror createInvoiceCashEntry: per-rate revenue + VAT credits, 1930 debit.
const inv = invoice as Invoice & { items?: InvoiceItem[] }
const items = inv.items ?? []
const isForeign = inv.currency !== 'SEK'
// Per-item rate aggregation (matches generatePerRateLines semantics).
// InvoiceItem.line_total is the NET line amount (EXCLUDES VAT): it sums
// to invoice.subtotal, and each line's vat_amount = line_total * rate. The
// commit path (generatePerRateLines) credits revenue with line_total
// directly; subtracting vat here double-subtracts VAT and unbalances the
// previewed verifikat against the 1930 debit (inv.total).
const byRate = new Map<number, { subtotal: number; vat: number }>()
if (items.length > 0) {
for (const it of items) {
const rate = it.vat_rate ?? 25
const itemVat = resolveSekAmount(it.vat_amount, null, inv.currency, inv.exchange_rate)
const itemTotal = resolveSekAmount(it.line_total, null, inv.currency, inv.exchange_rate)
const sub = roundOre(itemTotal)
const bucket = byRate.get(rate) ?? { subtotal: 0, vat: 0 }
bucket.subtotal += sub
bucket.vat += itemVat
byRate.set(rate, bucket)
}
} else {
// Fallback to invoice-level totals
const sub = resolveSekAmount(inv.subtotal, inv.subtotal_sek, inv.currency, inv.exchange_rate)
const vat = resolveSekAmount(inv.vat_amount, inv.vat_amount_sek, inv.currency, inv.exchange_rate)
byRate.set(inv.vat_rate ?? 25, { subtotal: sub, vat })
}
const creditLines: PreviewLine[] = []
for (const [rate, totals] of byRate) {
const vatTreatment = totals.vat > 0
? (rate === 25 ? 'standard_25' : rate === 12 ? 'reduced_12' : rate === 6 ? 'reduced_6' : inv.vat_treatment)
: inv.vat_treatment
const revenueAcct = getRevenueAccount(vatTreatment, entityType)
creditLines.push({
account_number: revenueAcct,
debit_amount: 0,
credit_amount: Math.round(totals.subtotal * 100) / 100,
description: `Försäljning ${rate}%`,
})
if (totals.vat > 0) {
creditLines.push({
account_number: getOutputVatAccount(vatTreatment),
debit_amount: 0,
credit_amount: Math.round(totals.vat * 100) / 100,
description: `Utgående moms ${rate}%`,
})
}
}
const totalCredits = creditLines.reduce((s, l) => s + l.credit_amount, 0)
const cashDebit = isForeign
? Math.round(totalCredits * 100) / 100
: resolveSekAmount(inv.total, inv.total_sek, inv.currency, inv.exchange_rate)
lines.push({
account_number: paymentAccount,
debit_amount: Math.round(cashDebit * 100) / 100,
credit_amount: 0,
description: 'Inbetalning från bank',
})
lines.push(...creditLines)
} else {
// Clearing entry. Delegates to the shared helper so the preview and
// the committed verifikat are byte-identical: fixing the prior
// bug where the preview ran `resolveSekAmount(tx.amount, null,
// INV.currency, INV.rate)`, treating the SEK tx number as if it
// were in the invoice's currency and multiplying by the invoice's
// rate. That produced a fictitious bank-leg and silently dropped
// the FX gain/loss for cross-currency invoices.
const inv = invoice as Invoice
const { lines: clearingLines } = buildInvoicePaymentClearingLines(
{
amount: transaction.amount,
amount_sek: transaction.amount_sek ?? null,
currency: transaction.currency,
exchange_rate: transaction.exchange_rate ?? null,
},
{
currency: inv.currency,
exchange_rate: inv.exchange_rate ?? null,
remaining_amount: inv.remaining_amount ?? null,
total: inv.total,
paid_amount: inv.paid_amount ?? null,
},
'Inbetalning kundfaktura',
fxConversion.required && !('error' in fxConversion)
? fxConversion.paid_in_invoice_currency
: undefined,
paymentAccount,
)
for (const line of clearingLines) {
lines.push({
account_number: line.account_number,
debit_amount: line.debit_amount,
credit_amount: line.credit_amount,
description: line.line_description ?? '',
})
}
}
return NextResponse.json({
entry_type: entryType,
lines,
invoice_already_booked: invoiceAlreadyBooked,
accounting_method: accountingMethod,
is_fully_paid: isFullyPaid,
fx_conversion: fxConversion,
})
},
)