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
+157 -2
View File
@@ -28,12 +28,29 @@ interface PreviewLine {
description: string
}
// Cross-currency conversion info returned by the preview route. When
// `required` is true the dialog surfaces a Valutaomräkning section so the
// user sees the rate + invoice-currency-equivalent before approving. When
// the Riksbanken lookup fails the dialog swaps in a manual-rate input.
type FxConversion =
| { required: false }
| {
required: true
tx_currency: string
invoice_currency: string
rate: number
rate_date: string
paid_in_invoice_currency: number
}
| { required: true; error: 'rate_unavailable'; tx_currency: string; invoice_currency: string }
interface MatchPreview {
entry_type: 'clearing' | 'cash'
lines: PreviewLine[]
invoice_already_booked: boolean
accounting_method: 'accrual' | 'cash'
is_fully_paid: boolean
fx_conversion?: FxConversion
}
// String-typed working copy of a line. The amount is a single value plus a
@@ -57,6 +74,10 @@ export interface ConfirmOpts {
credit_amount: number
line_description?: string
}>
// Manual SEK-per-invoice-currency override used when Riksbanken's rate
// for the payment date isn't available; the dialog asks the user to type
// the rate from their bank statement. Same field flows to the route.
manual_exchange_rate?: number
}
interface InvoiceMatchDialogProps {
@@ -109,6 +130,11 @@ export default function InvoiceMatchDialog({
const [previewFailed, setPreviewFailed] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editLines, setEditLines] = useState<EditableLine[]>([])
// Manual SEK-per-invoice-currency rate the user types when Riksbanken has
// no rate for the payment date. Empty string = no override; on submit it
// flows through ConfirmOpts.manual_exchange_rate to the route, which
// re-runs the preview math with the supplied rate.
const [manualRate, setManualRate] = useState<string>('')
// BAS accounts power the AccountCombobox suggestions in edit mode. Loaded
// once on dialog open; same endpoint that PaymentBookingDialog uses.
const [accounts, setAccounts] = useState<BASAccount[]>([])
@@ -138,6 +164,7 @@ export default function InvoiceMatchDialog({
setPreviewFailed(false)
setIsEditing(false)
setEditLines([])
setManualRate('')
return
}
let cancelled = false
@@ -233,7 +260,19 @@ export default function InvoiceMatchDialog({
}
})
: undefined
onConfirm({ ...(opts ?? {}), ...(linesPayload ? { lines: linesPayload } : {}) })
// Forward manual rate only when the preview indicated Riksbanken
// failed AND the user typed a value. Same-currency settlements and
// the auto-fetched cross-currency case both skip this field.
const fx = preview?.fx_conversion
const fxNeedsManualRate = fx?.required === true && 'error' in fx
const manualRateNum = fxNeedsManualRate ? parseAmount(manualRate) : 0
const manualRatePayload =
fxNeedsManualRate && manualRateNum > 0 ? { manual_exchange_rate: manualRateNum } : {}
onConfirm({
...(opts ?? {}),
...(linesPayload ? { lines: linesPayload } : {}),
...manualRatePayload,
})
}
const resetEdits = () => {
@@ -446,6 +485,112 @@ export default function InvoiceMatchDialog({
)
})()}
{/* Valutaomräkning section — only renders when the preview
route flagged a cross-currency settlement. Shows the
Riksbanken rate + invoice-currency-equivalent of the bank
payment + the projected post-payment invoice state. When
the rate lookup failed, swaps in a manual-rate input so
the user can type the rate from their bank statement and
retry. */}
{preview?.fx_conversion?.required && (() => {
const fx = preview.fx_conversion
if (!fx?.required) return null
const txAbs = transaction ? Math.abs(transaction.amount) : 0
const invRemaining = transaction?.potential_invoice?.remaining_amount
?? transaction?.potential_invoice?.total
?? 0
if ('error' in fx) {
// Riksbanken unavailable — show manual rate input.
return (
<div className="rounded-lg border border-warning/40 bg-warning/5 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-0.5 text-warning-foreground flex-shrink-0" />
<div className="flex-1 text-sm">
<p className="font-medium">{t('fx_rate_unavailable_title')}</p>
<p className="text-muted-foreground mt-1">
{t('fx_rate_unavailable_description', {
date: transaction ? formatDate(transaction.date) : '',
invoiceCurrency: fx.invoice_currency,
})}
</p>
</div>
</div>
{/* The typed rate flows through onConfirm.manual_exchange_rate
and the route recomputes server-side, so the footer
Confirm button is the trigger — no separate apply button.
Confirm stays disabled until a positive rate is entered
(see DialogFooter guard below). */}
<div className="space-y-1">
<label className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('fx_manual_rate_label')}
</label>
<Input
inputMode="decimal"
value={manualRate}
onChange={(e) => setManualRate(e.target.value)}
placeholder={t('fx_manual_rate_placeholder')}
className="tabular-nums"
/>
</div>
</div>
)
}
const paidInInvoice = fx.paid_in_invoice_currency
const remainingAfter = Math.max(0, Math.round((invRemaining - paidInInvoice) * 100) / 100)
const willBeFullyPaid = remainingAfter <= 0
// FX gain/loss for the kursvinst/kursförlust note: bankSek -
// arSek, where arSek = paidInInvoice × invoice.exchange_rate.
// Positive number = the SEK we received exceeded the SEK
// value of the debt reduction (kursvinst).
const invoiceRate = transaction?.potential_invoice?.exchange_rate ?? 0
const arSek = invoiceRate > 0 ? Math.round(paidInInvoice * invoiceRate * 100) / 100 : 0
const fxGain = invoiceRate > 0 ? Math.round((txAbs - arSek) * 100) / 100 : 0
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<p className="text-sm font-medium">{t('fx_title')}</p>
<p className="text-sm text-muted-foreground">
{t('fx_rate_description', {
date: fx.rate_date,
invoiceCurrency: fx.invoice_currency,
rate: fx.rate.toFixed(4).replace('.', ','),
})}
</p>
<div className="grid grid-cols-2 gap-3 text-sm pt-1">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('fx_paid_in_invoice_currency', { amount: '' }).replace(': ', '')}
</p>
<p className="font-medium tabular-nums mt-0.5">
{formatCurrency(paidInInvoice, fx.invoice_currency)}
</p>
</div>
<div className="text-right">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('fx_remaining_after', { amount: '' }).replace(': ', '')}
</p>
<p className="font-medium tabular-nums mt-0.5">
{formatCurrency(remainingAfter, fx.invoice_currency)}
</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
{willBeFullyPaid ? t('fx_status_paid') : t('fx_status_partially_paid')}
{Math.abs(fxGain) > 0.005 && (
<>
{' · '}
{fxGain > 0
? t('fx_gain_note', { amount: formatCurrency(fxGain, 'SEK') })
: t('fx_loss_note', { amount: formatCurrency(Math.abs(fxGain), 'SEK') })}
</>
)}
</p>
</div>
)
})()}
{/* Bookkeeping preview — editable. Read-only by default; user
clicks "Redigera" to switch the rows to inputs. */}
{(preview || previewFailed) && (
@@ -624,7 +769,17 @@ export default function InvoiceMatchDialog({
</Button>
<Button
onClick={() => handleConfirm()}
disabled={isConfirming || isCheckingDuplicate || (isEditing && !editValidation.isValid)}
disabled={
isConfirming ||
isCheckingDuplicate ||
(isEditing && !editValidation.isValid) ||
// Block confirm when cross-currency lookup failed and the user
// hasn't typed a manual rate yet. Same-currency and auto-rate
// paths pass through unaffected.
(preview?.fx_conversion?.required === true &&
'error' in preview.fx_conversion &&
parseAmount(manualRate) <= 0)
}
>
{isConfirming ? t('confirming') : t('confirm_match')}
</Button>