'use client' import { useEffect, useMemo, useState } from 'react' import { useAccounts } from '@/lib/reference-data/hooks' 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' 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(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(null) const [previewFailure, setPreviewFailure] = useState(null) const [isEditing, setIsEditing] = useState(false) const [editLines, setEditLines] = useState([]) // 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('') // BAS accounts power the AccountCombobox suggestions in edit mode. Loaded // once on dialog open; same endpoint that PaymentBookingDialog uses. const { accounts } = useAccounts() 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) => { 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 ( {matchTitle} {matchDescription} {transaction && (isCustomerInvoice || isSupplierInvoice) && (
{/* Duplicate-payment warning: customer-side only, only when a candidate exists */} {!targetBlocked && candidate && isCustomerInvoice && (

{t('duplicate_title')}

{/* 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). */}

{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), })}

{candidate.description && (

{candidate.description.length > 80 ? `${candidate.description.slice(0, 80).trimEnd()}…` : candidate.description}

)}
{onLinkToExisting && (
)}
)} {/* Transaction details */}

{t('transaction_label')}

{transaction.description}

{formatDate(transaction.date)} 0 ? 'text-success' : ''}`}> {transaction.amount > 0 ? '+' : ''} {formatCurrency(transaction.amount, transaction.currency)}
{/* 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 && (

{t('invoice_label')}

{t('invoice_number', { number: transaction.potential_invoice!.invoice_number ?? '' })}

{transaction.potential_invoice!.customer?.name || t('unknown_customer')}

{t('due_date', { date: formatDate(transaction.potential_invoice!.due_date) })} {formatCurrency( transaction.potential_invoice!.remaining_amount ?? transaction.potential_invoice!.total, transaction.potential_invoice!.currency, )}
)} {isSupplierInvoice && (

{t('supplier_invoice_label')}

{t('invoice_number', { number: transaction.potential_supplier_invoice!.supplier_invoice_number ?? '' })}

{t('arrival_number', { number: transaction.potential_supplier_invoice!.arrival_number ?? '' })}

{t('due_date', { date: formatDate(transaction.potential_supplier_invoice!.due_date) })} {formatCurrency( transaction.potential_supplier_invoice!.remaining_amount ?? transaction.potential_supplier_invoice!.total, transaction.potential_supplier_invoice!.currency, )}
)} {/* 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 (

{t(isSettled ? 'target_settled_title' : 'target_not_open_title')}

{t( isSettled ? 'target_settled_description' : 'target_not_open_description', )}

) } 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 (

{t('amounts_match')}

) } if (isOreRounding) { return (

{t('ore_rounding_note', { amount: formatCurrency(diff, transaction.currency), })}

) } return (

{t('amounts_differ')}

{sameCurrency ? ( <> {t('amount_diff', { amount: formatCurrency( Math.abs(txAbs - invRemaining), transaction.currency, ), })} {isSupplierInvoice && t('partial_payment_note')} ) : ( t('different_currencies') )}

) })()} {/* 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 && (
{/* Untinted title, matching the sibling fx_rate_unavailable panel below: the ochre lives in the icon and the surface, not in the heading. */}

{t('fx_invoice_rate_missing_title')}

{t('fx_invoice_rate_missing_description', { invoiceCurrency: invoiceCurrency ?? '', })}

)} {/* 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 (

{t('fx_rate_unavailable_title')}

{t('fx_rate_unavailable_description', { date: transaction ? formatDate(transaction.date) : '', invoiceCurrency: fx.invoice_currency, })}

{/* 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). */}
setManualRate(e.target.value)} placeholder={t('fx_manual_rate_placeholder')} className="tabular-nums" />
) } 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 (

{t('fx_title')}

{t('fx_rate_description', { date: fx.rate_date, invoiceCurrency: fx.invoice_currency, rate: fx.rate.toFixed(4).replace('.', ','), })}

{t('fx_paid_in_invoice_currency', { amount: '' }).replace(': ', '')}

{formatCurrency(paidInInvoice, fx.invoice_currency)}

{t('fx_remaining_after', { amount: '' }).replace(': ', '')}

{formatCurrency(remainingAfter, fx.invoice_currency)}

{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') })} )}

) })()} {/* 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)) && (

{t('booking_title')}

{preview && (
{isEditing && ( )}
)}
{/* 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 && (

{previewFailure.message ?? t('booking_unavailable')}

)} {preview && !isEditing && (
{t('booking_account')}
{t('booking_debit')}
{t('booking_credit')}
{/* 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) => (
{line.account_number}
{line.description}
{line.debit_amount > 0 ? formatCurrency(line.debit_amount, 'SEK') : ''}
{line.credit_amount > 0 ? formatCurrency(line.credit_amount, 'SEK') : ''}
))}
)} {preview && isEditing && (
{editLines.map((line, i) => (
updateEditLine(i, { account_number: acc })} /> 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. */}
updateEditLine(i, { amount: e.target.value })} className="text-right tabular-nums" placeholder="0" />
))}
{/* 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')}
{!editValidation.isBalanced && (

{t('booking_unbalanced', { diff: formatCurrency(Math.abs(editValidation.diff), 'SEK'), })}

)} {editValidation.accountInvalid && (

{t('booking_account_invalid')}

)}
)}
)} {!targetBlocked && (

{t('on_confirm_title')}

  • • {isSupplierInvoice ? t('on_confirm_link_supplier') : t('on_confirm_link_customer')}
  • • {isSupplierInvoice ? t('on_confirm_mark_paid_supplier') : t('on_confirm_mark_paid_customer')}
  • • {t('on_confirm_voucher')}
)}
)}
) }