Files
accounted/lib/bookkeeping/invoice-payment-lines.ts
T
Jakob WennbergandClaude Opus 4.8 b38b3d0230 fix(bookkeeping): settle öre differences to 3740 and improve supplier-invoice matching (#699)
Whole-krona Bankgiro/Swish payments of öre-bearing invoices were stranded
as partially_paid forever (e.g. 11 231 paid on an 11 231,25 invoice left
0,25 kr open). Book the sub-krona residual to BAS 3740 (Öres- och
kronutjämning) and settle the invoice in full, on both the supplier- and
customer-invoice match flows.

New shared pure helpers buildSupplierPaymentClearingLines +
planSupplierPayment mirror the customer-side primitives; routing preview
and commit through the same builder also fixes two pre-existing
preview↔commit drifts (payment account + line descriptions). Öre
absorption is accrual-only — cash entries book the full invoice, so
absorbing there would hide a 1930 discrepancy.

Also improves supplier-invoice ↔ bank matching:
- Pass-3 date window now spans [invoice_date-5, due_date+5] instead of
  due_date ±5, so early payments auto-match; an ambiguity guard demotes
  non-unique amount matches to suggestions.
- New retroactive matcher (on supplier_invoice.registered/.approved)
  surfaces the settling bank payment when the invoice is registered after
  the payment was imported. Matches are written as suggestions for
  one-click confirm-to-book, never silently auto-booked.

Tests: new unit tests for both pure helpers; extended matching, handler,
customer öre, and route suites. Full suite green (407 files / 5364 tests).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:09:39 +02:00

264 lines
11 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.
*/
export function buildInvoicePaymentClearingLines(
tx: PaymentClearingTx,
invoice: PaymentClearingInvoice,
description: string,
paidInInvoiceCurrency?: number,
): 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: '1930',
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 }
}