feat(invoices): cross-currency settlement + payment-status card (#615)

* 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>
This commit is contained in:
Jakob Wennberg
2026-06-01 10:45:51 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 13be0c569a
commit 2c59c3633f
12 changed files with 962 additions and 88 deletions
+11
View File
@@ -516,6 +516,17 @@ export const MatchInvoiceSchema = z
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
})).min(2).optional(),
// Optional caller-supplied SEK-per-invoice-currency rate for cross-currency
// settlement. Used when the Riksbanken lookup returns nothing (rate not
// published for that date) — the dialog surfaces an input so the user can
// type the rate from their bank statement. Ignored when tx.currency ===
// invoice.currency. The .max() is a sanity ceiling against pasted garbage /
// scientific-notation input silently corrupting the FX-diff posting and
// invoice_payments.amount — no supported currency's SEK rate approaches it
// (USD~10.5, EUR~11.5, GBP~13.5). It is a guard rail, not a precise band;
// the dialog's live preview (paid_in_invoice_currency + FX gain/loss) is
// what catches a plausible-but-wrong decimal-shift typo before confirm.
manual_exchange_rate: z.number().positive().max(100000).optional(),
})
.refine((v) => !v.force || !!v.expected_journal_entry_id, {
message: 'expected_journal_entry_id is required when force=true',
@@ -90,6 +90,67 @@ describe('buildInvoicePaymentClearingLines', () => {
expect(result.lines).toHaveLength(2)
})
it('partial cross-currency WITH paidInInvoiceCurrency: posts proportional 1510 credit + FX-diff line', () => {
// The proper-FX path (round-10): caller supplies the invoice-currency
// equivalent of the bank payment, computed at today's Riksbanken rate.
// The helper credits 1510 by that × invoice.exchange_rate (the
// booking rate) and posts the FX-diff line so the verifikat balances.
//
// Scenario: 1000 SEK bank tx, invoice 140 USD @ 9.30 (booked). Today
// Riksbanken rate: 10.45. paidInInvoiceCurrency = 1000/10.45 = 95.6938.
// arSek = 95.6938 × 9.30 = 889.95. fxDiff = 889.95 - 1000 = -110.05
// (negative → gain → 3960 Cr 110.05).
const result = buildInvoicePaymentClearingLines(
{ amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null },
{ currency: 'USD', exchange_rate: 9.3, remaining_amount: 140, total: 140, paid_amount: 0 },
'Inbetalning kundfaktura',
95.6938, // paidInInvoiceCurrency
)
expect(result.bankSek).toBe(1000)
expect(result.arSek).toBeCloseTo(889.95, 1)
expect(result.fxDiffSek).toBeCloseTo(-110.05, 1)
expect(result.lines).toHaveLength(3)
expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 1000 })
expect(result.lines[1]).toMatchObject({ account_number: '1510' })
expect(result.lines[2]).toMatchObject({
account_number: '3960',
line_description: 'Valutakursvinst',
})
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.abs(debit - credit)).toBeLessThanOrEqual(0.005)
})
it('cross-currency WITH paidInInvoiceCurrency, bank < booked: loss to 7960', () => {
// Mirror of the gain case above with the opposite sign — guards the
// kursförlust branch the route's gain-only assertion never reaches
// (Swedish compliance review, PR #615). Invoice 100 USD booked at 9.30
// (930 SEK on 1510); the 100 USD settlement only fetched 900 SEK at the
// weaker 9.00 payment-date rate. arSek = 100 × 9.30 = 930.
// fxDiff = 930 − 900 = +30 (positive → kursförlust → 7960 Dr 30).
const result = buildInvoicePaymentClearingLines(
{ amount: 900, amount_sek: null, currency: 'SEK', exchange_rate: null },
{ currency: 'USD', exchange_rate: 9.3, remaining_amount: 100, total: 100, paid_amount: 0 },
'Inbetalning kundfaktura',
100, // paidInInvoiceCurrency (full settlement at today's 9.00 rate)
)
expect(result.bankSek).toBe(900)
expect(result.arSek).toBeCloseTo(930, 2)
expect(result.fxDiffSek).toBeCloseTo(30, 2)
expect(result.lines).toHaveLength(3)
expect(result.lines[0]).toMatchObject({ account_number: '1930', debit_amount: 900 })
expect(result.lines[1]).toMatchObject({ account_number: '1510', credit_amount: 930 })
expect(result.lines[2]).toMatchObject({
account_number: '7960',
debit_amount: 30,
line_description: 'Valutakursförlust',
})
// Balanced to the öre: Dr 900 + 30 = 930 = Cr 930.
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.abs(debit - credit)).toBeLessThanOrEqual(0.005)
})
it('partial cross-currency payment defers FX: bank-leg = AR-leg = bankSek, no 3960/7960 line', () => {
// Invoice 140 USD @ 15.30 (2142 SEK booked on 1510)
// Bank receives 230 SEK — way below the 2142 remaining. If we credited
+33 -24
View File
@@ -99,19 +99,33 @@ export interface PaymentClearingLines {
* Build the verifikat lines for a customer-invoice payment matched against
* a bank tx. Pure — no DB calls. Caller decides how to persist.
*
* For same-currency invoices the FX diff is always 0 and only the two
* bank/AR lines are returned. For cross-currency, a 3960 or 7960 line is
* appended to balance the verifikat. Per the contract documented in this
* file, when the tx is cross-currency we assume the bank tx fully clears
* the invoice's remaining amount and book the full FX diff to one
* verifikat — same pattern as the match_batch_allocate RPC, which is the
* only other code path that posts FX diffs on customer-invoice
* settlements.
* # 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
@@ -136,30 +150,25 @@ export function buildInvoicePaymentClearingLines(
// 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 {
// Cross-currency: AR is denominated in invoice.currency and was
// booked on 1510 at invoice.exchange_rate. The remaining-amount × rate
// is the SEK currently sitting on 1510 for this invoice.
// 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)
// Branch on whether the bank tx fully clears (or over-pays) the
// remaining 1510 balance. Partial cross-currency must NOT credit the
// full remaining — that would zero 1510 in the GL while the invoice
// row stays at status=partially_paid, leaving the ledger inconsistent
// with the AR sub-ledger and over-stating FX gain/loss for the period.
// Defer the FX adjustment to the final settlement (when bank-SEK
// covers the full remaining), per BFL 5 kap 4–5§ "verifikat must
// reflect the actual affärshändelse".
if (bankSek >= arSekFullRemaining - 0.005) {
// Full payment of remaining (or overpay): clear AR and book FX diff.
arSek = arSekFullRemaining
fxDiffSek = TWO_DP(arSek - bankSek)
} else {
// Partial cross-currency: book 1930 / 1510 at bankSek (the actual
// SEK that moved), no FX line. The deferred FX diff lands on the
// verifikat that finally closes the invoice.
arSek = bankSek
fxDiffSek = 0
}
+3 -3
View File
@@ -346,12 +346,12 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Endast fakturor kan matchas mot en transaktion. Proforma och följesedel saknar momsskyldighet.',
message_en: 'Only invoices may be matched to a transaction; proforma and delivery notes have no VAT obligation.',
},
MATCH_INVOICE_CURRENCY_MISMATCH: {
MATCH_INVOICE_FX_RATE_UNAVAILABLE: {
httpStatus: 400,
message_sv:
'Transaktionens och fakturans valuta måste vara samma. För valutaomräkning, använd flerfaktura-matchningen som hanterar valutakursdifferenser på 3960/7960.',
'Kunde inte hämta valutakurs från Riksbanken för betalningsdatumet. Ange kursen manuellt från ditt bankutdrag (fältet manual_exchange_rate).',
message_en:
'Transaction and invoice currency must match. For cross-currency settlement, use the multi-invoice allocation flow which posts FX-diff lines on 3960/7960.',
'Could not retrieve an exchange rate from Riksbanken for the payment date. Provide the rate manually from your bank statement (manual_exchange_rate field).',
},
MATCH_INVOICE_ALREADY_PAID: {
httpStatus: 409,