'use client' import { useEffect, useMemo, useState } from 'react' import { 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 { 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 amount: number bank_account_number: string reason: 'exact_amount_same_date' | 'exact_amount_within_window' } 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 } export default function InvoiceMatchDialog({ open, onOpenChange, transaction, isConfirming, onConfirm, onLinkToExisting, }: InvoiceMatchDialogProps) { const t = useTranslations('tx_invoice_match') 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 [previewFailed, setPreviewFailed] = useState(false) 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) setPreviewFailed(false) 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) setPreviewFailed(false) return } async function loadPreview() { setPreviewFailed(false) try { const res = await fetch(previewUrl!) if (!res.ok) { if (!cancelled) setPreviewFailed(true) return } const data = (await res.json()) as MatchPreview if (!cancelled) { setPreview(data) setEditLines(data.lines.map(previewToEditable)) } } catch { if (!cancelled) setPreviewFailed(true) } } loadPreview() return () => { cancelled = true } }, [open, transactionId, isCustomerInvoice, isSupplierInvoice, invoiceId, supplierInvoiceId]) 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]) 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.reason === 'exact_amount_same_date' ? t('duplicate_body_same_date', { label: candidate.voucher_label, amount: formatCurrency(candidate.amount, transaction.currency), }) : t('duplicate_body_window', { label: candidate.voucher_label, amount: formatCurrency(candidate.amount, transaction.currency), 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') )}

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

{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 // 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 (

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

{t('booking_title')}

{preview && (
{isEditing && ( )}
)}
{previewFailed && !preview && (

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