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:
co-authored by
Claude Opus 4.8
parent
13be0c569a
commit
2c59c3633f
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user