'use client' import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { formatCurrency, formatDate } from '@/lib/utils' import { CheckCircle2, AlertTriangle } from 'lucide-react' import type { TransactionWithInvoice } from './transaction-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 InvoiceMatchDialogProps { open: boolean onOpenChange: (open: boolean) => void transaction: TransactionWithInvoice | null isConfirming: boolean onConfirm: (opts?: { force?: boolean; expected_journal_entry_id?: string }) => void onLinkToExisting?: (journalEntryId: string) => void } export default function InvoiceMatchDialog({ open, onOpenChange, transaction, isConfirming, onConfirm, onLinkToExisting, }: InvoiceMatchDialogProps) { const isSupplierInvoice = !!transaction?.potential_supplier_invoice const isCustomerInvoice = !!transaction?.potential_invoice const transactionId = transaction?.id ?? null // Customer-side only: pre-flight check for a manual verifikation that // already books this receipt. Supplier-side duplicate-payment surfacing // is handled by the mark-paid guard on the supplier-invoice side; here // we only need the customer flow for the reported issue. const [candidate, setCandidate] = useState(null) const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false) 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]) // The invoice candidate the dialog is about, normalized to a single shape. // Supplier invoices show the negative-amount paid-out match; customer // invoices show the positive-amount paid-in match. Each side carries its // own follow-up action language. const matchTitle = isSupplierInvoice ? 'Bekräfta leverantörsfakturamatchning' : 'Bekräfta fakturamatchning' const matchDescription = isSupplierInvoice ? 'Vill du koppla denna transaktion till leverantörsfakturan? Fakturan kommer att markeras som betald och en betalningsverifikation skapas.' : 'Vill du koppla denna transaktion till fakturan? Fakturan kommer att markeras som betald.' return ( {matchTitle} {matchDescription} {transaction && (isCustomerInvoice || isSupplierInvoice) && (
{/* Duplicate-payment warning — customer-side only, only when a candidate exists */} {candidate && isCustomerInvoice && (

Möjlig dubblettbokning

Det finns redan en bokförd verifikation {candidate.voucher_label} på samma belopp ({formatCurrency(candidate.amount, transaction.currency)}) {candidate.reason === 'exact_amount_same_date' ? 'på samma datum' : `inom ±7 dagar (${formatDate(candidate.entry_date)})`}. Har du redan bokfört denna betalning manuellt?

{candidate.description && ( // Truncate to a short head before render. The // description is free-text and may carry a customer // name or note that's not strictly required to // identify the verifikation (voucher_label + amount + // date already do that). Cap length to keep the // dialog tight and limit incidental PII surfacing // in the rendered DOM. GDPR Art.5(1)(c).

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

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

Transaktion

{transaction.description}

{formatDate(transaction.date)} 0 ? 'text-success' : ''}`}> {transaction.amount > 0 ? '+' : ''} {formatCurrency(transaction.amount, transaction.currency)}
{/* Invoice details */} {isCustomerInvoice && (

Faktura

Faktura {transaction.potential_invoice!.invoice_number}

{transaction.potential_invoice!.customer?.name || 'Okänd kund'}

Förfaller: {formatDate(transaction.potential_invoice!.due_date)} {formatCurrency( transaction.potential_invoice!.total, transaction.potential_invoice!.currency, )}
)} {isSupplierInvoice && (

Leverantörsfaktura

Faktura {transaction.potential_supplier_invoice!.supplier_invoice_number}

Ankomstnr: {transaction.potential_supplier_invoice!.arrival_number}

Förfaller: {formatDate(transaction.potential_supplier_invoice!.due_date)} {formatCurrency( transaction.potential_supplier_invoice!.total, transaction.potential_supplier_invoice!.currency, )}
)} {/* Amount comparison */} {(() => { const txAbs = Math.abs(transaction.amount) const invTotal = isSupplierInvoice ? transaction.potential_supplier_invoice!.remaining_amount ?? transaction.potential_supplier_invoice!.total : transaction.potential_invoice!.total const invCurrency = isSupplierInvoice ? transaction.potential_supplier_invoice!.currency : transaction.potential_invoice!.currency const sameCurrency = transaction.currency === invCurrency const amountsMatch = sameCurrency && Math.abs(txAbs - invTotal) < 0.01 if (amountsMatch) { return (

Beloppen stämmer

) } const diff = Math.abs(txAbs - invTotal) return (

Beloppen skiljer sig

Differens: {formatCurrency(diff, transaction.currency)} {!sameCurrency && ' (olika valutor)'} {isSupplierInvoice && diff > 0.01 && sameCurrency && ' — fakturan blir delbetald.'}

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

Vid bekräftelse:

  • • Transaktionen kopplas till {isSupplierInvoice ? 'leverantörsfakturan' : 'fakturan'}
  • • {isSupplierInvoice ? 'Leverantörsfakturan markeras som betald' : 'Fakturan markeras som betald'}
  • • Bokföringsverifikation skapas automatiskt
)}
) }