* feat(invoices): cross-currency settlement + payment-status card Two changes both surfaced by user feedback after PR #614: # 1. Invoice detail page: Betalningsstatus card The customer-invoice detail page now shows paid_amount + remaining_amount + the individual payment events whenever an invoice is partially_paid or paid (was previously only a single "Paid" line on fully-paid invoices, and nothing at all on partially_paid). Mirrors the supplier-invoice page's payment section. Each payment row links to its verifikat. # 2. Cross-currency match-invoice settlement Replaces the PR #614 round-9 block (MATCH_INVOICE_CURRENCY_MISMATCH) with proper FX-aware settlement. Flow: 1. Preview route detects tx.currency !== invoice.currency, fetches the Riksbanken spot rate for invoice.currency on tx.date (ML 8 kap 21–23§), and returns fx_conversion = { rate, rate_date, paid_in_invoice_currency }. When the lookup fails it returns fx_conversion.error = 'rate_unavailable'. 2. InvoiceMatchDialog renders a new Valutaomräkning card showing the rate + invoice-currency-equivalent + projected post-payment state + a one- line kursvinst/kursförlust note. When the lookup failed it swaps in a manual-rate input the user fills from their bank statement; the Confirm button blocks until a positive rate is supplied. 3. POST route does the same lookup (or accepts manual_exchange_rate from the request body), then: - paidInInvoiceCurrency = bankSek / rate (4dp precision) - invoice.paid_amount/remaining_amount accumulate in invoice currency - invoice_payments row records amount + currency = invoice.currency, exchange_rate = the rate actually used (not invoice.exchange_rate) - buildInvoicePaymentClearingLines gets paidInInvoiceCurrency so it credits 1510 by that × invoice.exchange_rate (booking rate) and posts the FX-diff line on 3960 (gain) or 7960 (loss) 4. buildInvoicePaymentClearingLines gains an optional fourth param. When supplied: proportional FX-aware AR-leg + balanced FX-diff. When omitted: pre-existing fallback (full-clear gets FX, partials defer). The change fixes the invoice.paid_amount accumulator bug that PR #614 round-9 worked around by blocking the case entirely. Now SEK→USD settlements actually work, with the verifikat balanced to the öre and the GL+sub-ledger in sync per BFL 5 kap 4–5§. Tests: - 3 new helper tests (paidInInvoiceCurrency happy path + edge cases) - 3 new route tests (Riksbanken happy path, lookup failure, manual rate) - All 4321 tests pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoices): align cross-currency match preview with commit + review cleanups Addresses PR #615 review feedback. Preview/commit divergence (Greptile P1): preview/route.ts computed paidAmount / isFullyPaid / useCashEntry from the raw SEK transaction.amount before the FX conversion ran. A 1 000 SEK payment against a 140 USD invoice made max(0, 140 − 1000) = 0 → is_fully_paid=true, so a cash-method unbooked invoice previewed a cash entry (Dr 1930 / Cr 30xx) while the POST handler — which converts first — commits the clearing entry (Dr 1930 / Cr 1510). The user approved one verifikat and a different one was booked. Move the FX lookup above the paid/remaining math so paidAmount derives from the invoice-currency conversion, mirroring the POST handler. Rate-unavailable stays non-fully-paid so the cash shape is never previewed on a guess. Add a preview-route regression test (cross-currency → clearing + not fully paid; same-currency cash path still previews the cash entry). Cleanups: - Bound manual_exchange_rate with .max(100000) as a sanity ceiling against pasted/garbage input corrupting the FX-diff posting (swarm V2.3). - Remove the invisible disabled placeholder retry button and its unused fx_manual_rate_retry i18n keys (Greptile P2). - Remove the now-unreachable MATCH_INVOICE_CURRENCY_MISMATCH error code (Greptile P2 dead code; confirmed zero references). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(invoices): record FX rate provenance + cover kursförlust path Follow-up to the PR #615 review (compliance swarm V16 / SOC 2 CC6.1 / GDPR Art.5(1)(f); Swedish accounting review). A manually-supplied cross-currency rate is a user-controlled money-path override of the ML 8 kap 21–23§ obligation and was indistinguishable from an automatic Riksbanken lookup in the audit trail. Tag the resolved rate with source: 'manual' | 'riksbanken' and: - write a "Manuell valutakurs <rate> <ccy>/SEK (betalningsdatum …)" note onto the existing invoice_payments.notes column when manual (BFL 5 kap 6–7§ — the verifikation must reflect the actual affärshändelse); - record rate_source + exchange_rate in payment_match_log.new_state. No schema change — both are existing columns/JSON. Tests: - cover the kursförlust (7960 Dr) branch of the cross-currency paidInInvoiceCurrency path — previously only the 3960 gain was asserted; - assert rate_source provenance ('manual' and 'riksbanken') reaches the match-log new_state on both FX paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
217 lines
9.0 KiB
TypeScript
217 lines
9.0 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 { 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
|
||
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'
|
||
|
||
let arSek: number
|
||
let fxDiffSek: number
|
||
|
||
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',
|
||
})
|
||
}
|
||
}
|
||
|
||
return { bankSek, arSek, fxDiffSek, lines }
|
||
}
|