Files
accounted/lib/bookkeeping/invoice-payment-lines.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

282 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Builds the journal-entry lines for the clearing entry that closes (fully
* or partially) a customer invoice against an actual bank transaction.
*
* The lines built here are the "Inbetalning kundfaktura" path under
* faktureringsmetoden (accrual): Dr 1930 / Cr 1510, with a 3960/7960
* FX-diff line when the invoice and the bank tx are in different currencies.
*
* Shared between:
* - GET /api/transactions/[id]/match-invoice/preview (read-only, drives
* the dialog the user confirms against)
* - POST /api/transactions/[id]/match-invoice (the commit path)
*
* Single source of truth so the preview and the committed verifikat are
* byte-identical. Earlier the two diverged on the cross-currency math:
* 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 stored rate. That produced a fictitious
* bank-leg amount and silently dropped the FX gain/loss.
*
* # Customer-invoice only
*
* This helper is the CUSTOMER side (kundfaktura): AR account 1510, FX gain
* 3960 (valutakursvinster rörelsefordringar), FX loss 7960
* (valutakursförluster rörelsefordringar), bank-leg = Dr. Supplier-side
* settlement has the opposite DR/CR polarity (Cr 1930 / Dr 2440-series) and
* a different account taxonomy; it lives in the match_batch_allocate RPC,
* not here. Do not call this helper from supplier-invoice flows.
*
* # Currency model
*
* tx.currency : currency of the bank tx (almost always SEK)
* tx.amount : amount in tx.currency
* tx.exchange_rate : populated at ingest only when tx.currency != SEK
* tx.amount_sek : pre-computed SEK at ingest for non-SEK tx
* invoice.currency : currency the invoice was issued in
* invoice.exchange_rate: the rate at which AR was originally booked on 1510
*
* Bank-leg (1930) = always the actual SEK that hit the bank.
* AR-leg (1510) = the SEK value of the customer-debt reduction at the
* INVOICE's stored rate (capped to bankSek on partials
* to keep 1510 in sync with invoice.remaining_amount).
* FX diff = (AR-leg SEK − Bank-leg SEK); sign drives 3960 vs 7960.
* Per BFL 5 kap 4-5§ every verifikat must balance to the
* öre; the FX diff line is what makes the cross-currency
* verifikat balance. Only emitted when the bank tx fully
* clears the invoice's remaining: partials defer the
* FX adjustment to the final settlement to avoid
* prematurely zeroing 1510 while the AR row still says
* partially_paid.
*/
import type { CreateJournalEntryLineInput } from '@/types'
import { ORE_TOLERANCE, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money'
import { resolveSekAmount } from './currency-utils'
const TWO_DP = (n: number): number => Math.round(n * 100) / 100
export interface PaymentClearingTx {
amount: number
amount_sek: number | null
currency: string
exchange_rate: number | null
}
export interface PaymentClearingInvoice {
currency: string
exchange_rate: number | null
remaining_amount: number | null
total: number
paid_amount: number | null
}
export interface PaymentClearingLines {
/** Actual SEK that hit the bank. The 1930 debit. */
bankSek: number
/** SEK value of the AR reduction at the invoice's stored rate. The 1510 credit. */
arSek: number
/**
* fxDiffSek = arSek − bankSek (this orientation matches what's needed to
* make the verifikat balance: positive value goes Dr 7960, negative
* value goes Cr 3960).
*
* Sign reading (note this is the OPPOSITE of an intuitive "profit"
* orientation: the value here is a balance-adjustment magnitude, not a
* P&L number, because AR is the side being cleared):
* positive → bank received FEWER SEK than AR was booked at → kursförlust → 7960 Dr
* negative → bank received MORE SEK than AR was booked at → kursvinst → 3960 Cr
* |value| ≤ 0.005 → no FX diff line emitted (floating-point tolerance,
* NOT a rounding allowance per BFL 5 kap 4-5§)
*
* If you want an intuitive "gain" number for UI display, use
* `bankSek - arSek` (negate this field). Do not consume the raw sign
* in caller logic without reading this paragraph.
*/
fxDiffSek: number
/**
* Öresavrundning residual (SEK), pure-SEK same-currency settlements only.
* remainingSek − bankSek: >0 → customer paid a sub-krona short (3740 debit,
* förlust); <0 → paid a sub-krona over (3740 credit, vinst); 0 → no 3740 line.
* When non-zero the AR leg (1510) is credited the FULL remaining so the
* invoice settles, and the residual balances the verifikat via 3740.
*/
oreRoundingSek: number
lines: CreateJournalEntryLineInput[]
}
/**
* Build the verifikat lines for a customer-invoice payment matched against
* a bank tx. Pure: no DB calls. Caller decides how to persist.
*
* # Same-currency
* Bank-leg = AR-leg = bankSek. No FX diff line.
*
* # Cross-currency with explicit paidInInvoiceCurrency (preferred path)
* The caller supplies how many units of the invoice's currency this bank
* payment satisfies (typically computed as `bankSek / today_rate` where
* `today_rate` is the Riksbanken spot rate on the payment date: see
* `app/api/transactions/[id]/match-invoice/route.ts`). The helper then:
* arSek = paidInInvoiceCurrency × invoice.exchange_rate (booking rate)
* fxDiffSek = arSek − bankSek
* For a partial cross-currency payment this credits 1510 by the
* proportional foreign amount (not the full remaining) and posts the
* accurate FX-diff line. The verifikat balances per BFL 5 kap 4-5§ and
* the GL stays in sync with the AR sub-ledger because both move in step.
*
* # Cross-currency without paidInInvoiceCurrency (fallback)
* Earlier behaviour, kept for callers that haven't been updated yet:
* if `bankSek >= remaining × rate`, book the full FX diff (full clear);
* otherwise defer (book 1930 = 1510 = bankSek with no FX line). The
* deferred path leaves the GL slightly understated until the final
* settlement closes the invoice.
*
* # paymentAccount
* The bank-leg account (the debit line below). Defaults to '1930': callers
* that haven't resolved the transaction's actual cash account keep booking
* there unchanged. Callers matching a real bank transaction should resolve
* it via resolveSettlementAccount (cash_account_id -> cash_accounts.ledger_
* account) and pass it here so a receipt into a non-primary bank/cash
* account (e.g. a secondary SEK account, or a EUR account on 1940) doesn't
* silently get misbooked to the primary account.
*/
export function buildInvoicePaymentClearingLines(
tx: PaymentClearingTx,
invoice: PaymentClearingInvoice,
description: string,
paidInInvoiceCurrency?: number,
/**
* BAS account for the bank leg (the 1930 debit below). Defaults to '1930'
* to preserve existing behaviour for every caller that doesn't pass one.
* Callers that know which cash account the underlying bank transaction
* actually belongs to (via cash_account_id -> cash_accounts.ledger_account,
* see lib/bookkeeping/settlement-account.ts) should resolve it and pass it
* here instead of always booking to the primary bank account.
*/
paymentAccount = '1930',
): PaymentClearingLines {
// Bank-leg: actual SEK that hit the bank. resolveSekAmount returns the
// raw amount for SEK txs and amount * exchange_rate for foreign txs
// (preferring the pre-computed amount_sek when set).
const bankSek = TWO_DP(
resolveSekAmount(
Math.abs(tx.amount),
tx.amount_sek != null ? Math.abs(tx.amount_sek) : null,
tx.currency,
tx.exchange_rate,
),
)
const sameCurrency = tx.currency === invoice.currency
const invoiceIsForeign = invoice.currency !== 'SEK'
// Pure SEK both sides: the only place whole-krona öresavrundning applies.
const pureSek = sameCurrency && invoice.currency === 'SEK'
let arSek: number
let fxDiffSek: number
let oreRoundingSek = 0
if (pureSek) {
// A whole-krona bank settlement of an öre-bearing SEK invoice leaves a
// sub-krona residual. Clear the FULL remaining off 1510 (invoice → paid)
// and let 3740 absorb the öre; a ≥1 kr short payment stays a real partial.
const remainingSek = TWO_DP(invoice.remaining_amount ?? invoice.total - (invoice.paid_amount ?? 0))
const oreDiff = TWO_DP(remainingSek - bankSek)
if (oreDiff !== 0 && Math.abs(oreDiff) < ORE_ROUNDING_SETTLEMENT_MAX) {
arSek = remainingSek
oreRoundingSek = oreDiff
} else {
arSek = bankSek
}
fxDiffSek = 0
} else if (sameCurrency || !invoiceIsForeign) {
// Same currency (or SEK invoice paid by SEK tx): the customer-debt
// reduction equals what hit the bank. No FX diff possible.
arSek = bankSek
fxDiffSek = 0
} else if (paidInInvoiceCurrency != null && paidInInvoiceCurrency > 0) {
// Proper FX path: caller computed the invoice-currency equivalent
// using today's spot rate. AR-leg comes off 1510 at the invoice's
// BOOKING rate (so the GL credit matches what was originally posted
// for those units of foreign currency). FX diff balances the verifikat.
const invRate = invoice.exchange_rate ?? 1
arSek = TWO_DP(paidInInvoiceCurrency * invRate)
fxDiffSek = TWO_DP(arSek - bankSek)
} else {
// Fallback when no paidInInvoiceCurrency is supplied (e.g. legacy
// callers, Riksbanken lookup failed with no manual override). Same
// pre-FX-rewrite behaviour: full-clear gets FX diff, partial defers.
const invRemainingForeign = invoice.remaining_amount ?? invoice.total - (invoice.paid_amount ?? 0)
const invRate = invoice.exchange_rate ?? 1
const arSekFullRemaining = TWO_DP(invRemainingForeign * invRate)
if (bankSek >= arSekFullRemaining - 0.005) {
arSek = arSekFullRemaining
fxDiffSek = TWO_DP(arSek - bankSek)
} else {
arSek = bankSek
fxDiffSek = 0
}
}
const lines: CreateJournalEntryLineInput[] = [
{
account_number: paymentAccount,
debit_amount: bankSek,
credit_amount: 0,
line_description: description,
},
{
account_number: '1510',
debit_amount: 0,
credit_amount: arSek,
line_description: description,
},
]
// Tolerance of 0.005 SEK is for floating-point equalisation only, not a
// rounding allowance per BFL 5 kap 4-5§. Same rationale as the balance
// pre-check in gnubok_bulk_book_transactions.
if (Math.abs(fxDiffSek) > 0.005) {
if (fxDiffSek > 0) {
// arSek > bankSek → bank received fewer SEK than booked. Loss → 7960 debit.
lines.push({
account_number: '7960',
debit_amount: Math.abs(fxDiffSek),
credit_amount: 0,
line_description: 'Valutakursförlust',
})
} else {
// bankSek > arSek → bank received more SEK than booked. Gain → 3960 credit.
lines.push({
account_number: '3960',
debit_amount: 0,
credit_amount: Math.abs(fxDiffSek),
line_description: 'Valutakursvinst',
})
}
}
// Öresavrundning (3740): pure-SEK only, mutually exclusive with an FX diff.
// The AR leg above is already the full remaining, so 3740 balances the
// verifikat: customer paid a sub-krona short → 3740 debit (förlust); over →
// credit (vinst). Opposite polarity to the supplier side (AP cleared by a Dr).
if (Math.abs(oreRoundingSek) >= ORE_TOLERANCE) {
if (oreRoundingSek > 0) {
lines.push({
account_number: '3740',
debit_amount: Math.abs(oreRoundingSek),
credit_amount: 0,
line_description: 'Öresavrundning',
})
} else {
lines.push({
account_number: '3740',
debit_amount: 0,
credit_amount: Math.abs(oreRoundingSek),
line_description: 'Öresavrundning',
})
}
}
return { bankSek, arSek, fxDiffSek, oreRoundingSek, lines }
}