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
+19
View File
@@ -1794,6 +1794,18 @@
"amount_diff": "Difference: {amount}",
"different_currencies": " (different currencies)",
"partial_payment_note": " — the invoice will become partially paid.",
"fx_title": "Currency conversion",
"fx_rate_description": "Riksbanken mid-rate {date}: 1 {invoiceCurrency} = {rate} SEK",
"fx_paid_in_invoice_currency": "Payment equals: {amount}",
"fx_remaining_after": "Remaining after payment: {amount}",
"fx_status_paid": "Invoice will be: Paid",
"fx_status_partially_paid": "Invoice will be: Partially paid",
"fx_rate_unavailable_title": "Exchange rate unavailable",
"fx_rate_unavailable_description": "Riksbanken has not published a rate for {date}. Enter the rate from your bank statement (SEK per 1 {invoiceCurrency}).",
"fx_manual_rate_label": "Manual rate",
"fx_manual_rate_placeholder": "e.g. 10.4500",
"fx_gain_note": "FX gain: {amount}",
"fx_loss_note": "FX loss: {amount}",
"on_confirm_title": "On confirmation:",
"on_confirm_link_supplier": "The transaction is linked to the supplier invoice",
"on_confirm_link_customer": "The transaction is linked to the invoice",
@@ -2211,6 +2223,13 @@
"correction_prompt": "Something wrong? Create a correction verifikat",
"paid_card_title": "Paid",
"paid_received_at": "Payment received {date}",
"payment_status_card_title": "Payment status",
"payment_status_paid_label": "Paid",
"payment_status_remaining_label": "Remaining",
"payment_status_payments_heading": "Payments",
"payment_status_view_voucher": "Voucher {label}",
"payment_status_view_voucher_unlinked": "No voucher linked",
"payment_status_empty": "No payments recorded yet.",
"reminders_card_title": "Reminders",
"reminders_description": "Automatic reminders are sent at 15, 30 and 45 days overdue",
"reminders_empty": "No reminders have been sent yet.",
+19
View File
@@ -1794,6 +1794,18 @@
"amount_diff": "Differens: {amount}",
"different_currencies": " (olika valutor)",
"partial_payment_note": " — fakturan blir delbetald.",
"fx_title": "Valutaomräkning",
"fx_rate_description": "Riksbankens mittkurs {date}: 1 {invoiceCurrency} = {rate} SEK",
"fx_paid_in_invoice_currency": "Inbetalning motsvarar: {amount}",
"fx_remaining_after": "Återstår efter betalning: {amount}",
"fx_status_paid": "Faktura blir: Betald",
"fx_status_partially_paid": "Faktura blir: Delbetald",
"fx_rate_unavailable_title": "Saknar valutakurs",
"fx_rate_unavailable_description": "Riksbanken har ingen publicerad kurs för {date}. Ange kursen från ditt bankutdrag (SEK per 1 {invoiceCurrency}).",
"fx_manual_rate_label": "Manuell kurs",
"fx_manual_rate_placeholder": "t.ex. 10,4500",
"fx_gain_note": "Kursvinst: {amount}",
"fx_loss_note": "Kursförlust: {amount}",
"on_confirm_title": "Vid bekräftelse:",
"on_confirm_link_supplier": "Transaktionen kopplas till leverantörsfakturan",
"on_confirm_link_customer": "Transaktionen kopplas till fakturan",
@@ -2211,6 +2223,13 @@
"correction_prompt": "Något fel? Skapa ändringsverifikation",
"paid_card_title": "Betald",
"paid_received_at": "Betalning mottagen {date}",
"payment_status_card_title": "Betalningsstatus",
"payment_status_paid_label": "Betalt",
"payment_status_remaining_label": "Återstår",
"payment_status_payments_heading": "Betalningar",
"payment_status_view_voucher": "Verifikat {label}",
"payment_status_view_voucher_unlinked": "Saknar verifikat",
"payment_status_empty": "Inga registrerade betalningar ännu.",
"reminders_card_title": "Påminnelser",
"reminders_description": "Automatiska påminnelser skickas vid 15, 30 och 45 dagars förfallen betalning",
"reminders_empty": "Inga påminnelser har skickats ännu.",