Files
accounted/components/transactions/InvoiceMatchDialog.tsx
T
Mattsson 392e847c1e fix(transactions): block invalid invoice match targets (#1294)
Classify customer and supplier invoice targets as matchable, settled, or otherwise not open. Block invalid targets with localized guidance while retaining the valid partial-payment flow and add focused regression coverage.

Fixes #1260
2026-07-30 11:20:08 +02:00

1010 lines
46 KiB
TypeScript

'use client'
import { useEffect, useMemo, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { isInvoiceBookingRateMissing, previewedFxGainSek } from './invoice-match-fx'
import {
getInvoiceMatchTargetState,
getSupplierInvoiceMatchTargetState,
} from '@/lib/invoices/matchable-statuses'
import { CheckCircle2, AlertTriangle, Trash2, Plus, Pencil } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
import type { BASAccount } from '@/types'
interface DuplicateCandidate {
journal_entry_id: string
voucher_label: string
entry_date: string
description: string | null
/** The voucher leg's SEK debit: always kronor, never the bank line's own
* (possibly foreign) amount. Render with an explicit 'SEK'. */
amount: number
bank_account_number: string
/** 'date_window_only' = the amount test never ran (no SEK value on the bank
* line); the copy must not claim an amount match for that shape. */
reason: 'exact_amount_same_date' | 'exact_amount_within_window' | 'date_window_only'
/** False when the amounts were never compared (mirrors
* lib/transactions/booking-duplicate-detection.ts). */
amount_verified: boolean
}
interface PreviewLine {
account_number: string
debit_amount: number
credit_amount: number
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
// side (debit / credit): modeling a verifikationsrad as one positive number
// with a direction matches how Swedish accountants think and tightens the
// failure modes (you can't accidentally fill both sides). Conversion back
// to the server's { debit_amount, credit_amount } shape happens at submit.
interface EditableLine {
account_number: string
side: 'debit' | 'credit'
amount: string
description: string
}
export interface ConfirmOpts {
force?: boolean
expected_journal_entry_id?: string
lines?: Array<{
account_number: string
debit_amount: number
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 {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
isConfirming: boolean
onConfirm: (opts?: ConfirmOpts) => void
onLinkToExisting?: (journalEntryId: string) => void
}
function previewToEditable(line: PreviewLine): EditableLine {
const isDebit = line.debit_amount > 0
return {
account_number: line.account_number,
side: isDebit ? 'debit' : 'credit',
amount: String(isDebit ? line.debit_amount : line.credit_amount),
description: line.description,
}
}
function parseAmount(s: string): number {
const n = Number(s.replace(',', '.'))
return Number.isFinite(n) ? n : 0
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
/**
* A preview request that came back non-2xx. `code` is the structured error
* code from the canonical envelope when the body carried one, `message` the
* locale-resolved sentence. Both null for a transport failure (offline,
* proxy error page), where the generic fallback copy is all we can honestly
* say.
*/
interface PreviewFailure {
code: string | null
message: string | null
}
export default function InvoiceMatchDialog({
open,
onOpenChange,
transaction,
isConfirming,
onConfirm,
onLinkToExisting,
}: InvoiceMatchDialogProps) {
const t = useTranslations('tx_invoice_match')
const uiLocale = useLocale() === 'en' ? ('en' as const) : ('sv' as const)
const isSupplierInvoice = !!transaction?.potential_supplier_invoice
const isCustomerInvoice = !!transaction?.potential_invoice
const transactionId = transaction?.id ?? null
// The suggestion pointer is written once at import time and never revisited,
// so the invoice it names may since have been settled by a DIFFERENT
// transaction. The read paths filter those out, but the row in hand can
// still be stale (fetched before the other match, or settled in another
// tab), so re-check here rather than trust the pointer.
//
// This is not an advisory guard: the match routes reject any target outside
// their open-status CAS lists, so there is no "match anyway" that could
// succeed. Distinguish a paid or zero-balance target from a different
// non-open status so the blocking copy explains the actual problem.
const targetMatchState = isSupplierInvoice
? getSupplierInvoiceMatchTargetState(transaction!.potential_supplier_invoice)
: isCustomerInvoice
? getInvoiceMatchTargetState(transaction!.potential_invoice)
: null
const targetBlocked = targetMatchState !== null && targetMatchState !== 'matchable'
const [candidate, setCandidate] = useState<DuplicateCandidate | null>(null)
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false)
const invoiceId = transaction?.potential_invoice?.id ?? null
const supplierInvoiceId = transaction?.potential_supplier_invoice?.id ?? null
const [preview, setPreview] = useState<MatchPreview | null>(null)
const [previewFailure, setPreviewFailure] = useState<PreviewFailure | null>(null)
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[]>([])
useEffect(() => {
if (!open) return
let cancelled = false
;(async () => {
try {
const res = await fetch('/api/bookkeeping/accounts')
if (!res.ok) return
const data = await res.json()
if (!cancelled) setAccounts((data?.data as BASAccount[]) ?? [])
} catch {
// Non-fatal: combobox just shows no suggestions, user can still
// type the number manually.
}
})()
return () => {
cancelled = true
}
}, [open])
useEffect(() => {
if (!open || !transactionId || targetBlocked) {
setPreview(null)
setPreviewFailure(null)
setIsEditing(false)
setEditLines([])
setManualRate('')
return
}
let cancelled = false
const previewUrl = isCustomerInvoice && invoiceId
? `/api/transactions/${transactionId}/match-invoice/preview?invoice_id=${invoiceId}`
: isSupplierInvoice && supplierInvoiceId
? `/api/transactions/${transactionId}/match-supplier-invoice/preview?supplier_invoice_id=${supplierInvoiceId}`
: null
if (!previewUrl) {
setPreview(null)
setPreviewFailure(null)
return
}
async function loadPreview() {
setPreviewFailure(null)
try {
const res = await fetch(previewUrl!)
if (!res.ok) {
// The preview route builds its clearing lines with the same helper
// the POST commits with, so it refuses in exactly the places the
// commit would: a foreign invoice with no booking rate makes
// buildInvoicePaymentClearingLines throw
// MATCH_INVOICE_BOOKING_RATE_MISSING and this GET returns 400. Keep
// the code and the Swedish sentence rather than collapsing every
// failure into "could not preview, continue or cancel": that copy
// invites an action the server has already decided to reject.
let failure: PreviewFailure = { code: null, message: null }
try {
const body = (await res.json()) as { error?: { code?: unknown } }
if (body?.error && typeof body.error === 'object') {
failure = {
code: typeof body.error.code === 'string' ? body.error.code : null,
message: getErrorMessage(body, { locale: uiLocale }),
}
}
} catch {
// Non-JSON body (proxy/edge error page): generic copy is all we have.
}
if (!cancelled) setPreviewFailure(failure)
return
}
const data = (await res.json()) as MatchPreview
if (!cancelled) {
setPreview(data)
setEditLines(data.lines.map(previewToEditable))
}
} catch {
if (!cancelled) setPreviewFailure({ code: null, message: null })
}
}
loadPreview()
return () => {
cancelled = true
}
}, [
open,
transactionId,
isCustomerInvoice,
isSupplierInvoice,
invoiceId,
supplierInvoiceId,
targetBlocked,
uiLocale,
])
useEffect(() => {
if (
!open ||
!transactionId ||
!isCustomerInvoice ||
!onLinkToExisting ||
targetBlocked
) {
setCandidate(null)
setIsCheckingDuplicate(false)
return
}
let cancelled = false
async function check() {
setIsCheckingDuplicate(true)
try {
const res = await fetch(`/api/transactions/${transactionId}/duplicate-payment-check`)
if (!res.ok) return
const data = (await res.json()) as { candidate: DuplicateCandidate | null }
if (!cancelled) setCandidate(data.candidate ?? null)
} catch {
// Fail-open: hide the warning panel; the server still enforces the guard.
} finally {
if (!cancelled) setIsCheckingDuplicate(false)
}
}
check()
return () => {
cancelled = true
}
}, [open, transactionId, isCustomerInvoice, onLinkToExisting, targetBlocked])
// Live balance + validity. The dialog disables Confirm while edit mode is
// active and the entry is invalid; an out-of-balance entry can't be sent.
const editValidation = useMemo(() => {
if (!isEditing) return { isBalanced: true, isValid: true, diff: 0, totalDebit: 0, totalCredit: 0, accountInvalid: false }
const totalDebit = round2(
editLines.filter((l) => l.side === 'debit').reduce((s, l) => s + parseAmount(l.amount), 0),
)
const totalCredit = round2(
editLines.filter((l) => l.side === 'credit').reduce((s, l) => s + parseAmount(l.amount), 0),
)
const isBalanced = totalDebit === totalCredit && totalDebit > 0
const accountInvalid = editLines.some((l) => !/^\d{4}$/.test(l.account_number.trim()))
return {
isBalanced,
accountInvalid,
isValid: isBalanced && !accountInvalid,
diff: round2(totalDebit - totalCredit),
totalDebit,
totalCredit,
}
}, [isEditing, editLines])
// Cross-currency settlement whose invoice carries no booked exchange rate.
// The kursvinst/kursförlust is then UNDEFINED, not zero: the SEK value the
// receivable was posted at is unknown, so nothing on this screen can honestly
// state what the FX result of the settlement is, and the booking path refuses
// to invent one. Third state, distinct from "no FX at all" (a SEK invoice)
// and from "FX with a real computed result". See ./invoice-match-fx.ts.
const invoiceCurrency = transaction?.potential_invoice?.currency ?? null
const invoiceRateMissing = isInvoiceBookingRateMissing({
transactionCurrency: transaction?.currency,
invoiceCurrency,
invoiceExchangeRate: transaction?.potential_invoice?.exchange_rate,
previewEntryType: preview?.entry_type ?? null,
previewErrorCode: previewFailure?.code ?? null,
})
const handleConfirm = (opts?: { force?: boolean; expected_journal_entry_id?: string }) => {
const linesPayload = isEditing && preview && editValidation.isValid
? editLines.map((l) => {
const amount = round2(parseAmount(l.amount))
return {
account_number: l.account_number.trim(),
debit_amount: l.side === 'debit' ? amount : 0,
credit_amount: l.side === 'credit' ? amount : 0,
line_description: l.description?.trim() || undefined,
}
})
: undefined
// 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 = () => {
if (preview) setEditLines(preview.lines.map(previewToEditable))
}
const addEditLine = () => {
setEditLines((prev) => [...prev, { account_number: '', side: 'debit', amount: '', description: '' }])
}
const removeEditLine = (i: number) => {
setEditLines((prev) => prev.filter((_, idx) => idx !== i))
}
const updateEditLine = (i: number, patch: Partial<EditableLine>) => {
setEditLines((prev) => prev.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
}
const matchTitle = isSupplierInvoice ? t('title_supplier') : t('title_customer')
const matchDescription = targetBlocked
? t('description_blocked')
: isSupplierInvoice
? t('description_supplier')
: t('description_customer')
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{matchTitle}</DialogTitle>
<DialogDescription>{matchDescription}</DialogDescription>
</DialogHeader>
{transaction && (isCustomerInvoice || isSupplierInvoice) && (
<div className="space-y-4">
{/* Duplicate-payment warning: customer-side only, only when a candidate exists */}
{!targetBlocked && candidate && isCustomerInvoice && (
<div className="rounded-lg border border-warning/40 bg-warning/10 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5 text-warning-foreground" />
<div className="text-sm space-y-1">
<p className="font-medium text-warning-foreground">{t('duplicate_title')}</p>
{/* candidate.amount is the voucher leg's SEK debit
(duplicate-payment-detection.ts), so it is formatted as
SEK regardless of the transaction's currency: an
11 500 kr leg must never print as "11 500,00 EUR".
The unverified shape (date_window_only) uses copy that
does NOT claim an amount match: the amounts were never
compared (the bank line has no stored SEK value). */}
<p className="text-muted-foreground">
{candidate.reason === 'date_window_only' || candidate.amount_verified === false
? t('duplicate_body_unverified', {
label: candidate.voucher_label,
amount: formatCurrency(candidate.amount, 'SEK'),
date: formatDate(candidate.entry_date),
})
: candidate.reason === 'exact_amount_same_date'
? t('duplicate_body_same_date', {
label: candidate.voucher_label,
amount: formatCurrency(candidate.amount, 'SEK'),
})
: t('duplicate_body_window', {
label: candidate.voucher_label,
amount: formatCurrency(candidate.amount, 'SEK'),
date: formatDate(candidate.entry_date),
})}
</p>
{candidate.description && (
<p className="text-xs text-muted-foreground truncate">
{candidate.description.length > 80
? `${candidate.description.slice(0, 80).trimEnd()}…`
: candidate.description}
</p>
)}
</div>
</div>
{onLinkToExisting && (
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="default"
size="sm"
onClick={() => onLinkToExisting(candidate.journal_entry_id)}
disabled={isConfirming}
className="sm:flex-1"
>
{t('link_to_existing', { label: candidate.voucher_label })}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleConfirm({
force: true,
expected_journal_entry_id: candidate.journal_entry_id,
})
}
disabled={isConfirming}
className="text-muted-foreground"
>
{t('create_new_anyway')}
</Button>
</div>
)}
</div>
)}
{/* Transaction details */}
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">{t('transaction_label')}</p>
<p className="font-medium">{transaction.description}</p>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{formatDate(transaction.date)}</span>
<span className={`font-medium ${transaction.amount > 0 ? 'text-success' : ''}`}>
{transaction.amount > 0 ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</span>
</div>
</div>
{/* Invoice details. Both branches show remaining_amount (what is
still owed) rather than the original total, so a partially-paid
invoice displays the actual figure the user is matching against
and the card can never contradict the amount comparison below.
The supplier branch used to render .total while the comparison
measured against remaining_amount: on a partially-paid invoice
that put "1 250 kr" on screen next to "Differens: 1 250 kr". */}
{isCustomerInvoice && (
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">{t('invoice_label')}</p>
<p className="font-medium">
{t('invoice_number', { number: transaction.potential_invoice!.invoice_number ?? '' })}
</p>
<p className="text-sm text-muted-foreground">
{transaction.potential_invoice!.customer?.name || t('unknown_customer')}
</p>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t('due_date', { date: formatDate(transaction.potential_invoice!.due_date) })}
</span>
<span className="font-medium">
{formatCurrency(
transaction.potential_invoice!.remaining_amount ?? transaction.potential_invoice!.total,
transaction.potential_invoice!.currency,
)}
</span>
</div>
</div>
)}
{isSupplierInvoice && (
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">{t('supplier_invoice_label')}</p>
<p className="font-medium">
{t('invoice_number', { number: transaction.potential_supplier_invoice!.supplier_invoice_number ?? '' })}
</p>
<p className="text-sm text-muted-foreground">
{t('arrival_number', { number: transaction.potential_supplier_invoice!.arrival_number ?? '' })}
</p>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t('due_date', { date: formatDate(transaction.potential_supplier_invoice!.due_date) })}
</span>
<span className="font-medium">
{formatCurrency(
transaction.potential_supplier_invoice!.remaining_amount ??
transaction.potential_supplier_invoice!.total,
transaction.potential_supplier_invoice!.currency,
)}
</span>
</div>
</div>
)}
{/* Amount comparison. Compares the bank tx against what the
customer STILL OWES (remaining_amount), not the original
invoice.total: otherwise a 1 250 SEK invoice with a prior
230 SEK partial would show "Differens: 250 kr" when a 1 000
SEK top-up arrives, instead of the actual 20 kr shortfall.
The customer branch previously fell back to .total; both
branches now mirror the supplier branch's correct logic. */}
{(() => {
// A blocked target makes the amount comparison below
// meaningless, and no outcome it describes is reachable.
if (targetBlocked) {
const isSettled = targetMatchState === 'settled'
return (
<div className="flex items-start gap-2 p-3 rounded-lg bg-warning/10 text-warning-foreground">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium">
{t(isSettled ? 'target_settled_title' : 'target_not_open_title')}
</p>
<p>
{t(
isSettled
? 'target_settled_description'
: 'target_not_open_description',
)}
</p>
</div>
</div>
)
}
const txAbs = Math.abs(transaction.amount)
const invRemaining = isSupplierInvoice
? transaction.potential_supplier_invoice!.remaining_amount ?? transaction.potential_supplier_invoice!.total
: transaction.potential_invoice!.remaining_amount ?? transaction.potential_invoice!.total
const invCurrency = isSupplierInvoice
? transaction.potential_supplier_invoice!.currency
: transaction.potential_invoice!.currency
const sameCurrency = transaction.currency === invCurrency
// Cross-currency "match" comparison is meaningless without an FX
// conversion: show the explicit different-currencies warning
// and skip the numeric match check. The committed verifikat is
// built by buildInvoicePaymentClearingLines, which posts the
// FX diff to 3960/7960 so the books balance correctly even
// when the on-screen numbers can't be naively compared.
const diff = Math.abs(txAbs - invRemaining)
const amountsMatch = sameCurrency && diff < 0.01
// A sub-krona SEK difference is öresavrundning: the backend books
// it to 3740 and settles the invoice in full instead of leaving it
// delbetald (see ORE_ROUNDING_SETTLEMENT_MAX). SEK only: keep the
// 1 kr band in sync with the server constant.
const isOreRounding =
sameCurrency && transaction.currency === 'SEK' && diff >= 0.01 && diff < 1.0
if (amountsMatch) {
return (
<div className="flex items-center gap-2 p-3 rounded-lg bg-success/10 text-success">
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<p className="text-sm font-medium">{t('amounts_match')}</p>
</div>
)
}
if (isOreRounding) {
return (
<div className="flex items-start gap-2 p-3 rounded-lg bg-success/10 text-success">
<CheckCircle2 className="h-4 w-4 flex-shrink-0 mt-0.5" />
<p className="text-sm font-medium">
{t('ore_rounding_note', {
amount: formatCurrency(diff, transaction.currency),
})}
</p>
</div>
)
}
return (
<div className="flex items-start gap-2 p-3 rounded-lg bg-warning/10 text-warning-foreground">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium">{t('amounts_differ')}</p>
<p>
{sameCurrency ? (
<>
{t('amount_diff', {
amount: formatCurrency(
Math.abs(txAbs - invRemaining),
transaction.currency,
),
})}
{isSupplierInvoice && t('partial_payment_note')}
</>
) : (
t('different_currencies')
)}
</p>
</div>
</div>
)
})()}
{/* Third FX state: the invoice is in a foreign currency and no
booking rate was ever stored, so the SEK value of the 1510
receivable is unknown and the kursvinst/kursförlust on
settlement is not a computable number.
buildInvoicePaymentClearingLines refuses to build the verifikat
(MATCH_INVOICE_BOOKING_RATE_MISSING) on both the preview GET and
the commit POST, so there is nothing to approve: say that up
front instead of showing a confident zero. Rendered on its own
rather than inside the Valutaomräkning card below, because in
this state the preview 400s and that card never renders. */}
{!targetBlocked && invoiceRateMissing && (
<div className="rounded-lg border border-warning/40 bg-warning/5 p-4">
<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">
{/* Untinted title, matching the sibling
fx_rate_unavailable panel below: the ochre lives in the
icon and the surface, not in the heading. */}
<p className="font-medium">{t('fx_invoice_rate_missing_title')}</p>
<p className="text-muted-foreground mt-1">
{t('fx_invoice_rate_missing_description', {
invoiceCurrency: invoiceCurrency ?? '',
})}
</p>
</div>
</div>
</div>
)}
{/* Valutaomräkning section: only renders when the preview
route flagged a cross-currency settlement (a SEK invoice
paid in SEK has no FX effect and renders nothing here).
Shows the Riksbanken rate + invoice-currency-equivalent of
the bank payment + the projected post-payment invoice state.
When the payment-date rate lookup failed, swaps in a
manual-rate input so the user can type the rate from their
bank statement and retry. */}
{!targetBlocked && preview?.fx_conversion?.required && (() => {
const fx = preview.fx_conversion
if (!fx?.required) return null
// fx_conversion is only produced by the customer-invoice preview
// route. No invoice row means there is nothing honest to show:
// render nothing rather than fall back to zeroed money.
const inv = transaction.potential_invoice
if (!inv) return null
const invRemaining = inv.remaining_amount ?? inv.total
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
// The kursvinst/kursförlust note is READ OFF the previewed
// verifikat (3960 credit = vinst, 7960 debit = förlust) instead
// of recomputed from the invoice here: see previewedFxGainSek.
const fxGain = previewedFxGainSek(preview.lines)
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')}
{!invoiceRateMissing && 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. Suppressed
entirely when the invoice's missing booking rate is what
blocked the preview: the ochre panel above already owns that
story, and an empty "Bokföring" card with a second phrasing of
the same refusal reads as two separate problems. */}
{!targetBlocked && (preview || (previewFailure && !invoiceRateMissing)) && (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{t('booking_title')}</p>
{preview && (
<div className="flex gap-2">
{isEditing && (
<Button variant="ghost" size="sm" onClick={resetEdits} disabled={isConfirming}>
{t('booking_reset')}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing((v) => !v)}
disabled={isConfirming}
>
{isEditing ? t('booking_done_editing') : (
<>
<Pencil className="h-3 w-3 mr-1" />
{t('booking_edit')}
</>
)}
</Button>
</div>
)}
</div>
{/* Prefer the route's own structured message (resolved through
getErrorMessage, so it follows the UI locale) over the
generic "continue or cancel" copy: when the server named a
reason the user can act on it, and "continue" is often not
actually available. */}
{previewFailure && !preview && (
<p className="text-sm text-muted-foreground">
{previewFailure.message ?? t('booking_unavailable')}
</p>
)}
{preview && !isEditing && (
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-x-3 gap-y-1 text-sm tabular-nums">
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('booking_account')}
</div>
<div />
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground text-right">
{t('booking_debit')}
</div>
<div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground text-right">
{t('booking_credit')}
</div>
{/* Verifikat amounts are always denominated in SEK (the
bookkeeping home currency): the preview route builds
every line via resolveSekAmount. Format them as SEK,
NOT transaction.currency, otherwise a foreign-currency
payment (e.g. 19 USD) shows the converted SEK figure
with the wrong symbol ("175,28 US$" instead of
"175,28 kr"). */}
{preview.lines.map((line, i) => (
<div key={i} className="contents">
<div className="font-medium">{line.account_number}</div>
<div className="text-muted-foreground truncate">{line.description}</div>
<div className="text-right">
{line.debit_amount > 0
? formatCurrency(line.debit_amount, 'SEK')
: ''}
</div>
<div className="text-right">
{line.credit_amount > 0
? formatCurrency(line.credit_amount, 'SEK')
: ''}
</div>
</div>
))}
</div>
)}
{preview && isEditing && (
<div className="space-y-2">
{editLines.map((line, i) => (
<div
key={i}
className="grid grid-cols-[minmax(180px,1.6fr)_minmax(0,1fr)_140px_110px_28px] gap-2 items-center"
>
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(acc) => updateEditLine(i, { account_number: acc })}
/>
<Input
value={line.description}
onChange={(e) => updateEditLine(i, { description: e.target.value })}
placeholder={t('booking_description_placeholder')}
/>
{/* Side toggle: segmented control. Clicking either
button picks that side; the amount stays the
same. */}
<div className="inline-flex rounded-md border bg-background overflow-hidden h-9">
<button
type="button"
onClick={() => updateEditLine(i, { side: 'debit' })}
className={cn(
'flex-1 px-2 text-xs font-medium transition-colors',
line.side === 'debit'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/60',
)}
aria-pressed={line.side === 'debit'}
>
{t('booking_debit')}
</button>
<button
type="button"
onClick={() => updateEditLine(i, { side: 'credit' })}
className={cn(
'flex-1 px-2 text-xs font-medium border-l transition-colors',
line.side === 'credit'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/60',
)}
aria-pressed={line.side === 'credit'}
>
{t('booking_credit')}
</button>
</div>
<Input
inputMode="decimal"
value={line.amount}
onChange={(e) => updateEditLine(i, { amount: e.target.value })}
className="text-right tabular-nums"
placeholder="0"
/>
<Button
variant="ghost"
size="icon"
onClick={() => removeEditLine(i)}
disabled={editLines.length <= 2}
aria-label={t('booking_remove_line')}
className="h-8 w-8"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<div className="flex items-center justify-between pt-1">
<Button variant="ghost" size="sm" onClick={addEditLine}>
<Plus className="h-3 w-3 mr-1" />
{t('booking_add_line')}
</Button>
<div className="text-xs tabular-nums text-muted-foreground">
{/* SEK: edited verifikat rows are home-currency, like the read-only preview above. */}
{t('booking_debit')} {formatCurrency(editValidation.totalDebit, 'SEK')}
{' / '}
{t('booking_credit')} {formatCurrency(editValidation.totalCredit, 'SEK')}
</div>
</div>
{!editValidation.isBalanced && (
<p className="text-xs text-destructive">
{t('booking_unbalanced', {
diff: formatCurrency(Math.abs(editValidation.diff), 'SEK'),
})}
</p>
)}
{editValidation.accountInvalid && (
<p className="text-xs text-destructive">{t('booking_account_invalid')}</p>
)}
</div>
)}
</div>
)}
{!targetBlocked && (
<div className="rounded-lg bg-muted/50 p-4 space-y-2">
<p className="text-sm font-medium">{t('on_confirm_title')}</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> {isSupplierInvoice ? t('on_confirm_link_supplier') : t('on_confirm_link_customer')}</li>
<li> {isSupplierInvoice ? t('on_confirm_mark_paid_supplier') : t('on_confirm_mark_paid_customer')}</li>
<li> {t('on_confirm_voucher')}</li>
</ul>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isConfirming}>
{t('cancel')}
</Button>
<Button
onClick={() => handleConfirm()}
disabled={
isConfirming ||
isCheckingDuplicate ||
// Blocked target: the route rejects this unconditionally, so the
// button has no reachable success path.
targetBlocked ||
(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) ||
// Cross-currency invoice with no booked exchange rate: the FX
// result of the settlement is uncomputable, so there is no
// honest entry to approve and the POST would reject it with the
// same MATCH_INVOICE_BOOKING_RATE_MISSING the preview already
// returned. A hand-written entry is still allowed through: the
// user has then supplied the numbers themselves rather than
// approving a fabricated preview. (Edit mode requires a
// successful preview to enter, so today this only relaxes the
// guard in the defense-in-depth branch of
// isInvoiceBookingRateMissing.)
(invoiceRateMissing && !(isEditing && editValidation.isValid))
}
>
{isConfirming ? t('confirming') : t('confirm_match')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}