* 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>
282 lines
12 KiB
TypeScript
282 lines
12 KiB
TypeScript
/**
|
||
* 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 }
|
||
}
|