'use client' import { useState, useEffect, useMemo } from 'react' import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' import { useToast } from '@/components/ui/use-toast' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { Loader2, Search } from 'lucide-react' import { Input } from '@/components/ui/input' import { amountVarianceForMatch, calculateMatchConfidence, calculateMerchantSimilarity, } from '@/lib/documents/core-receipt-matcher' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import type { InvoiceExtractionResult } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' // TransactionMatchPicker // // Opens from the InvoiceInboxWorkspace FieldsRail when the user clicks // "Matcha mot transaktion" on an inbox item whose matched_transaction_id is // null. Lists *uncategorised* transactions only: matching points an underlag // at the bank payment you're about to book, so already-booked rows are out of // scope (and surfacing them would invite a double-booking via "Bokför // manuellt"). Two modes: // • Suggested (no search): every uncategorised company transaction, scored // via lib/documents/core-receipt-matcher and sorted best-first. No date // window: over-fetching is cheap for a manual picker and a hard window // silently dropped late payments out of the candidate set. // • Search (≥2 chars): the same uncategorised set across ALL dates, narrowed // server-side by description/merchant. This is the fix for "not all // transactions come up to search for": the old client-only filter could // only see the windowed rows already fetched. // Amounts are compared currency-aware (see scoring memo): same-currency raw, // otherwise normalised to SEK via the underlag's FX rate. User picks one → // POST /items/:id/match-transaction → onMatched callback. interface RawTransaction { id: string date: string description: string | null merchant_name: string | null amount: number currency: string | null amount_sek: number | null exchange_rate: number | null } interface CandidateTransaction { id: string date: string description: string | null merchant_name: string | null amount: number currency: string /** SEK-equivalent for non-SEK transactions, for the "≈ … kr" hint. */ amountSek: number | null confidence: number reasons: string[] } interface Props { open: boolean onClose: () => void inboxItemId: string extractedData: InvoiceExtractionResult | null onMatched: (transactionId: string) => void } // Currencies the /api/currency/rate endpoint (Riksbanken) can resolve. Used // to normalise a foreign-currency underlag total into SEK so it can be // compared against SEK bank charges. const SUPPORTED_FX = ['EUR', 'USD', 'GBP', 'NOK', 'DKK'] // Date tolerance used only for *ranking* candidates (not for filtering, there // is no longer a date window). Far wider than the receipt matcher's tight ±3d // default so a payment landing weeks or months after the invoice still earns // partial date credit and the true match floats to the top, instead of every // candidate collapsing to "Svag match". const MATCH_DATE_TOLERANCE_DAYS = 120 // Minimum characters before a keystroke flips the picker into server-side // search mode. Below this we stay on the scored suggestions and just narrow // them client-side for instant feedback. const SEARCH_MIN_CHARS = 2 export default function TransactionMatchPicker({ open, onClose, inboxItemId, extractedData, onMatched, }: Props) { const supabase = useMemo(() => createClient(), []) const { company } = useCompany() const { toast } = useToast() // Starts true so the first paint after opening shows skeletons, not a flash // of the empty state before the fetch effect runs. const [loading, setLoading] = useState(true) const [rawRows, setRawRows] = useState([]) const [search, setSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') const [matchingId, setMatchingId] = useState(null) // SEK per unit of the underlag currency (e.g. ~11.5 for EUR). null = SEK // underlag, fetch pending, or unsupported/failed: amount matching then // falls back to same-currency-only. const [fxRate, setFxRate] = useState(null) // ── Underlag (receipt) facts pulled from extracted_data ────── const rawInvoiceDate = extractedData?.invoice?.invoiceDate ?? null const hasInvoiceDate = useMemo(() => { if (!rawInvoiceDate) return false return !Number.isNaN(new Date(rawInvoiceDate).getTime()) }, [rawInvoiceDate]) // Default to today if no date was extracted so scoring still has an anchor. const invoiceDate = useMemo(() => { if (!rawInvoiceDate) return new Date() const parsed = new Date(rawInvoiceDate) return Number.isNaN(parsed.getTime()) ? new Date() : parsed }, [rawInvoiceDate]) const total = extractedData?.totals?.total ?? null const receiptCurrency = (extractedData?.invoice?.currency ?? 'SEK').toUpperCase() const supplier = extractedData?.supplier?.name ?? null // SEK value of the underlag total. For a SEK underlag that's the total // itself; for a foreign one it needs the fetched FX rate. const receiptSek = useMemo(() => { if (total == null) return null if (receiptCurrency === 'SEK') return total if (fxRate != null) return Math.round(total * fxRate * 100) / 100 return null }, [total, receiptCurrency, fxRate]) // True when the underlag is in a foreign currency we can't price right now: // amount matching is unavailable, so we say so instead of mis-ranking. const amountMatchUnavailable = total != null && receiptCurrency !== 'SEK' && receiptSek == null // ── Reset transient state each time the dialog opens ───────── useEffect(() => { if (!open) return setSearch('') setDebouncedSearch('') setRawRows([]) setLoading(true) }, [open]) // ── Debounce the search term feeding the server query ──────── useEffect(() => { const t = setTimeout(() => setDebouncedSearch(search), 300) return () => clearTimeout(t) }, [search]) // ── Fetch the underlag's FX rate (foreign currency only) ───── useEffect(() => { if (!open) return setFxRate(null) if (receiptCurrency === 'SEK' || !SUPPORTED_FX.includes(receiptCurrency)) return let cancelled = false const dateParam = hasInvoiceDate && rawInvoiceDate ? `&date=${rawInvoiceDate}` : '' fetch(`/api/currency/rate?currency=${receiptCurrency}${dateParam}`) .then((r) => (r.ok ? r.json() : null)) .then((body) => { if (cancelled) return const rate = body?.data?.rate if (typeof rate === 'number' && rate > 0) setFxRate(rate) }) .catch(() => { /* leave null: cross-currency amount signal simply drops out */ }) return () => { cancelled = true } }, [open, receiptCurrency, hasInvoiceDate, rawInvoiceDate]) // ── Fetch candidate rows (suggested vs search mode) ────────── useEffect(() => { if (!open) return if (!company) return // provider still hydrating let cancelled = false setLoading(true) ;(async () => { // Strip PostgREST filter-DSL structural chars before interpolating into // `.or()`. Commas separate OR-conditions and parentheses group nested // filters, so leaving them in would let a search term inject a synthetic // clause (e.g. "Acme,company_id.neq.…"). `%` and `\` are LIKE wildcards/ // escapes we also drop. `.` is safe: it stays inside the ilike value. const safe = debouncedSearch.trim().replace(/[%,()\\]/g, ' ').trim() const searchMode = safe.length >= SEARCH_MIN_CHARS // Defense-in-depth: RLS only narrows to "any company the user belongs // to". Multi-tenant users (consultants) would otherwise see other // companies' transactions. Filter to the active company explicitly. // Uncategorised only (journal_entry_id IS NULL) in both modes: see the // component header. The search just adds a server-side name filter so // matches outside the suggested set still surface. let query = supabase .from('transactions') .select( 'id, date, description, merchant_name, amount, currency, amount_sek, exchange_rate', ) .eq('company_id', company.id) .is('journal_entry_id', null) if (searchMode) { query = query .or(`description.ilike.%${safe}%,merchant_name.ilike.%${safe}%`) .order('date', { ascending: false }) .limit(100) } else { // Recent first; scoring floats the best match up regardless of date. query = query.order('date', { ascending: false }).limit(300) } const { data, error } = await query if (cancelled) return if (error) { toast({ title: 'Kunde inte hämta transaktioner', description: getUserErrorMessage(error), variant: 'destructive', }) setRawRows([]) setLoading(false) return } setRawRows((data ?? []) as RawTransaction[]) setLoading(false) })() return () => { cancelled = true } }, [open, supabase, company, debouncedSearch, toast]) // ── Score + sort (currency-aware) ──────────────────────────── const candidates = useMemo(() => { const scored = rawRows.map((tx) => { const txCurrency = (tx.currency ?? 'SEK').toUpperCase() const txSek = txCurrency === 'SEK' ? tx.amount : resolveSekAmount(tx.amount, tx.amount_sek, tx.currency, tx.exchange_rate) // Currency-aware variance: null when uncomparable, which makes the // matcher drop the amount signal instead of matching 750 EUR to 750 SEK. const amountVariance = amountVarianceForMatch( total, receiptCurrency, receiptSek, tx.amount, txCurrency, txSek, ) const dateVariance = Math.abs( (new Date(tx.date).getTime() - invoiceDate.getTime()) / (1000 * 60 * 60 * 24), ) const merchant = tx.merchant_name || tx.description || '' const similarity = supplier ? calculateMerchantSimilarity(supplier, merchant) : 0 const { confidence, matchReasons } = calculateMatchConfidence( dateVariance, amountVariance, similarity, MATCH_DATE_TOLERANCE_DAYS, ) return { id: tx.id, date: tx.date, description: tx.description ?? null, merchant_name: tx.merchant_name ?? null, amount: tx.amount, currency: txCurrency, amountSek: txCurrency === 'SEK' ? null : Math.round(txSek * 100) / 100, confidence, reasons: matchReasons, } }) scored.sort((a, b) => b.confidence - a.confidence) return scored }, [rawRows, invoiceDate, total, receiptCurrency, receiptSek, supplier]) // Instant client-side narrowing while the debounced server query catches up. const filtered = useMemo(() => { const q = search.trim().toLowerCase() if (!q) return candidates return candidates.filter((c) => { const hay = `${c.description ?? ''} ${c.merchant_name ?? ''}`.toLowerCase() return hay.includes(q) }) }, [candidates, search]) // Any typed text counts as "searching" for labelling/empty-state purposes // (1 char narrows the suggested set client-side; ≥2 also hits the server). const hasSearchText = search.trim().length > 0 async function handlePick(transactionId: string) { setMatchingId(transactionId) try { const res = await fetch( `/api/extensions/ext/invoice-inbox/items/${inboxItemId}/match-transaction`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transaction_id: transactionId }), }, ) const json = (await res.json().catch(() => ({}))) as { error?: string } if (!res.ok) { toast({ title: 'Kunde inte matcha', description: json.error ?? `HTTP ${res.status}`, variant: 'destructive', }) return } onMatched(transactionId) onClose() } finally { setMatchingId(null) } } return ( !o && onClose()}> Matcha mot transaktion Välj banktransaktionen som hör till underlaget. {/* Underlag reference: what we're matching against, so a currency or amount mismatch with a candidate is obvious at a glance. */} {(total != null || supplier) && (
Underlag {supplier && {supplier}} {total != null && ( {formatCurrency(total, receiptCurrency)} {receiptSek != null && receiptCurrency !== 'SEK' && ( {' '} ≈ {formatCurrency(receiptSek, 'SEK')} )} )} {hasInvoiceDate && rawInvoiceDate && ( {formatDate(rawInvoiceDate)} )}
)} {amountMatchUnavailable && (

Växelkurs saknas för {receiptCurrency}: kandidaterna rankas på datum och leverantör, inte belopp.

)}
setSearch(e.target.value)} placeholder="Sök i alla transaktioner…" className="pl-9" />
{hasSearchText ? 'Sökresultat' : 'Föreslagna matchningar'} {!loading && {filtered.length} st}
{loading ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) : filtered.length === 0 ? (

{hasSearchText ? `Inga okategoriserade transaktioner matchar "${search.trim()}".` : 'Inga okategoriserade transaktioner att matcha mot.'}

) : ( filtered.map((c) => { const tier = c.confidence >= 0.8 ? 'success' : c.confidence >= 0.5 ? 'warning' : 'outline' const tierLabel = c.confidence >= 0.8 ? 'Stark match' : c.confidence >= 0.5 ? 'Möjlig match' : 'Svag match' const isMatching = matchingId === c.id return ( ) }) )}
) }