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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user