'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 { 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 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, setAccounts] = useState([]) 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) { 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, uiLocale]) useEffect(() => { if (!open || !transactionId || !isCustomerInvoice || !onLinkToExisting) { setCandidate(null) 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]) // 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 = isSupplierInvoice ? t('description_supplier') : t('description_customer') return ( {matchTitle} {matchDescription} {transaction && (isCustomerInvoice || isSupplierInvoice) && (
{/* Duplicate-payment warning: customer-side only, only when a candidate exists */} {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. Shows remaining_amount (what the customer still owes) rather than the original total, so a partially- paid invoice displays the actual figure the user is matching against. Mirrors the supplier-invoice block below. */} {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!.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. */} {(() => { 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. */} {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. */} {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. */} {(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')}

)}
)}
)} {/* What will happen */}

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