'use client' import { useState, useEffect, useMemo } from 'react' import { useTranslations } from 'next-intl' import { createClient } from '@/lib/supabase/client' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { formatCurrency, formatDate, cn } from '@/lib/utils' import { roundOre } from '@/lib/money' import { Search, FileText, Loader2, Landmark } from 'lucide-react' import { useCompany } from '@/contexts/CompanyContext' import type { Invoice, Customer } from '@/types' import type { PotentialRotRutPayoutRequest, TransactionWithInvoice } from './transaction-types' import { DOMESTIC_CURRENCY, normalizeCurrency, rankInvoicesByAmountProximity, } from './invoice-candidate-ranking' import { expectedRotRutPayoutAmount, OPEN_ROT_RUT_PAYOUT_STATUSES, } from '@/lib/invoices/rot-rut-payout-matching' type OpenInvoice = Invoice & { customer?: Customer } interface InvoicePickerProps { transaction: TransactionWithInvoice onSelect: (invoice: OpenInvoice) => void /** Pick one or several open ROT/RUT begäran instead of an invoice * (Skatteverkets utbetalning). A row click hands over that begäran alone; * the checkboxes build a bundle for a transfer that paid several beslut, * which the automatic set matcher refuses when two begäran carry the same * amount (#2425). The section only renders when the company has one. */ onSelectRotRutPayout?: (requests: PotentialRotRutPayoutRequest[]) => void } type RotRutRequestRow = { id: string name: string deduction_type: 'rot' | 'rut' status: string requested_total: number | string decided_total: number | string | null settlement_journal_entry_id: string | null items?: Array<{ requested_amount: number | string invoice?: { invoice_number: string | null } | { invoice_number: string | null }[] | null }> | null } export default function InvoicePicker({ transaction, onSelect, onSelectRotRutPayout }: InvoicePickerProps) { const t = useTranslations('tx_invoice_picker') const { company } = useCompany() const supabase = useMemo(() => createClient(), []) const [invoices, setInvoices] = useState([]) const [rotRutRequests, setRotRutRequests] = useState([]) const [isLoading, setIsLoading] = useState(true) const [search, setSearch] = useState('') // Begäran ticked for a bundled handoff; empty until the user ticks one. const [pickedRequestIds, setPickedRequestIds] = useState>(() => new Set()) // Boolean, not the callback: a fresh function identity per parent render // must not refetch the list. const wantRotRutRequests = !!onSelectRotRutPayout useEffect(() => { if (!company) return // Capture the company id once so the async closure below never // dereferences a `company` that has flipped to null between renders. // The earlier non-null assertions allowed a stale render to query // against an undefined company_id; pinning the value avoids that. const companyId = company.id let cancelled = false async function load() { setIsLoading(true) // Filter out fully-settled invoices defensively: match-invoice should // flip status to 'paid' on full settlement, but a stale 'sent'/'overdue' // row with remaining_amount=0 would otherwise be selectable here and // could be matched a second time, double-booking the income. // Also exclude proformas (PF- series): proforma is not a faktura per // ML 17 kap 24§, has no VAT obligation, and must never be matched // against a bank receipt or trigger a verifikation. const { data } = await supabase .from('invoices') .select('*, customer:customers(*)') .eq('company_id', companyId) .eq('document_type', 'invoice') .is('credited_invoice_id', null) .in('status', ['sent', 'overdue', 'partially_paid']) .gt('remaining_amount', 0) .order('invoice_date', { ascending: false }) .limit(200) if (cancelled) return const all = (data as OpenInvoice[]) || [] // Status-leak guard: if an invoice still says 'sent'/'overdue' but // already has a payment voucher attached (manual or system), hide it. // Partially-paid invoices intentionally pass through: they may take // more payments. Mirrors the server-side filter in findMatchingInvoices. const fullIds = all .filter((inv) => inv.status === 'sent' || inv.status === 'overdue') .map((inv) => inv.id) let visible = all if (fullIds.length > 0) { const { data: paid } = await supabase .from('invoice_payments') .select('invoice_id') .eq('company_id', companyId) .in('invoice_id', fullIds) .not('journal_entry_id', 'is', null) if (cancelled) return const paidSet = new Set( ((paid as { invoice_id: string }[] | null) ?? []).map((r) => r.invoice_id), ) visible = all.filter((inv) => !paidSet.has(inv.id)) } // Open ROT/RUT begäran: the manual fallback when the SKV payout got no // auto-hint (amount differs from the request, or the row predates the // hint). Non-fatal: the invoice list renders either way. if (wantRotRutRequests) { const { data: requests } = await supabase .from('rot_rut_payout_requests') .select( 'id, name, deduction_type, status, requested_total, decided_total, settlement_journal_entry_id, items:rot_rut_payout_request_items(requested_amount, invoice:invoices(invoice_number))', ) .eq('company_id', companyId) .in('status', [...OPEN_ROT_RUT_PAYOUT_STATUSES]) .is('settlement_journal_entry_id', null) .order('created_at', { ascending: false }) if (cancelled) return setRotRutRequests( ((requests as RotRutRequestRow[] | null) ?? []).map((req) => ({ id: req.id, name: req.name, deduction_type: req.deduction_type, status: req.status, requested_total: req.requested_total, decided_total: req.decided_total, settlement_journal_entry_id: req.settlement_journal_entry_id, invoices: (req.items ?? []).map((item) => { const inv = Array.isArray(item.invoice) ? item.invoice[0] : item.invoice return { invoice_number: inv?.invoice_number ?? null, requested_amount: item.requested_amount } }), })), ) } setInvoices(visible) setIsLoading(false) } load() return () => { cancelled = true } }, [company, supabase, wantRotRutRequests]) const sorted = useMemo(() => { const filtered = !search ? invoices : invoices.filter((inv) => { const q = search.toLowerCase() return ( (inv.invoice_number ?? '').toLowerCase().includes(q) || (inv.customer?.name ?? '').toLowerCase().includes(q) ) }) // Amount proximity is only meaningful between comparable amounts: ranking // a 1 000 EUR invoice as a perfect hit for a 1 000 SEK deposit put the // wrong row first. Foreign invoices stay in the list either way; see // ./invoice-candidate-ranking. return rankInvoicesByAmountProximity(filtered, { amount: transaction.amount, currency: transaction.currency, amountSek: transaction.amount_sek, }) }, [invoices, search, transaction.amount, transaction.currency, transaction.amount_sek]) if (isLoading) { return (
{t('loading')}
) } const txAmount = roundOre(transaction.amount) // Skatteverket pays out in kronor only; the match route refuses other // currencies, so a foreign-currency row must not be offered a begäran. const txIsSek = (transaction.currency || 'SEK').toUpperCase() === 'SEK' const pickedRequests = rotRutRequests.filter((request) => pickedRequestIds.has(request.id)) const pickedTotal = roundOre( pickedRequests.reduce((sum, request) => sum + expectedRotRutPayoutAmount(request), 0), ) const pickedDiff = roundOre(Math.abs(pickedTotal - txAmount)) const pickedExact = pickedRequests.length > 0 && pickedDiff < 0.005 const togglePicked = (id: string, checked: boolean) => { setPickedRequestIds((prev) => { const next = new Set(prev) if (checked) next.add(id) else next.delete(id) return next }) } // The settle service books a bundle largest first (the matcher's order): // hand the manual pick over in the same order so the voucher reads alike. const handOverPicked = () => { if (!onSelectRotRutPayout || pickedRequests.length === 0) return onSelectRotRutPayout( [...pickedRequests].sort( (a, b) => expectedRotRutPayoutAmount(b) - expectedRotRutPayoutAmount(a), ), ) } const rotRutSection = onSelectRotRutPayout && txIsSek && rotRutRequests.length > 0 ? (

{t('rot_rut_section_title')}

{rotRutRequests.map((request) => { const expected = expectedRotRutPayoutAmount(request) const exact = Math.abs(expected - txAmount) < 0.005 const picked = pickedRequestIds.has(request.id) return (
) })} {pickedRequests.length > 0 && (

{t('rot_rut_picked_sum', { count: pickedRequests.length, amount: formatCurrency(pickedTotal, DOMESTIC_CURRENCY), })}

{!pickedExact && (

{t('rot_rut_picked_diff', { amount: formatCurrency(pickedDiff, DOMESTIC_CURRENCY) })}

)}
)}
) : null if (invoices.length === 0) { return (
{rotRutSection}

{t('empty')}

) } return (
{rotRutSection}
setSearch(e.target.value)} className="pl-9" autoFocus />
{sorted.map(({ invoice, proximity }) => { const remaining = invoice.remaining_amount ?? invoice.total const { exact, close, candidateSek } = proximity const invoiceCurrency = normalizeCurrency(invoice.currency) // The currency earns a marker only when it deviates from the bank // row's: the same marker on every row would say nothing (design.md, // "chips mark exceptions"). It is what explains why a row is or is // not ranked as close. const foreignCurrency = proximity.basis !== 'same_currency' return ( ) })} {sorted.length === 0 && (

{t('no_search_results', { term: search })}

)}
) }