'use client' import { useTranslations } from 'next-intl' import { CalendarClock } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' import { AccountNumber } from '@/components/ui/account-number' import { formatCurrency } from '@/lib/utils' import { resolveReverseChargeRate, isReverseChargeBasisAccount, generateReverseChargeBasisLines, } from '@/lib/bookkeeping/vat-entries' import { resolveBookingAccount, itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions' import type { Supplier } from '@/types' interface ReviewLineItem { description: string amount: number account_number: string vat_rate: number // When set, the user typed the deductible VAT explicitly (manual override). // Used for bilförmån 50%, representation tak, FX-rundningar etc. vat_amount?: number // Self-assessed VAT rate for omvänd skattskyldighet (0.06/0.12/0.25). The // supplier charges no VAT (vat_rate = 0); this drives the fiktiv-moms preview. reverse_charge_rate?: number // Periodisering: when both dates are set, the registration entry books the // net to the 17xx interim account instead of account_number (mirrored via // resolveBookingAccount so this preview matches the saved verifikat). accrual_period_start?: string | null accrual_period_end?: string | null accrual_balance_account?: string | null } const accrualMonth = (date: string): string => date.slice(0, 7) interface SupplierInvoiceReviewContentProps { supplier: Supplier invoiceNumber: string invoiceDate: string dueDate: string deliveryDate?: string currency: string exchangeRate?: string reverseCharge: boolean paymentReference?: string items: ReviewLineItem[] subtotal: number totalVat: number total: number } function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } interface JournalPreviewLine { account_number: string description: string debit: number credit: number } function getOutputVatAccount(rate: number): string { if (rate === 0.12) return '2624' if (rate === 0.06) return '2634' return '2614' } function buildJournalPreview( items: ReviewLineItem[], subtotal: number, totalVat: number, total: number, reverseCharge: boolean, supplierType: string | undefined, // FX multiplier applied to every amount. 1 when the invoice is in SEK or // when no rate is set. Matches what the backend writes — items go through // resolveSekAmount(item.line_total, null, currency, exchange_rate), so the // saved verifikation is always in SEK, never in invoice currency. fxRate: number, ): JournalPreviewLine[] { const lines: JournalPreviewLine[] = [] const toSek = (n: number) => Math.round(n * fxRate * 100) / 100 // Aggregate expense amounts by booking account (in SEK). Periodiserade // lines book their net to the 17xx interim account instead of the cost // account — same resolveBookingAccount the entry generator uses, so the // preview matches the saved verifikat. const expenseByAccount = new Map() for (const item of items) { const bookingAccount = resolveBookingAccount('expense', item, item.account_number) const current = expenseByAccount.get(bookingAccount) || 0 expenseByAccount.set(bookingAccount, current + toSek(item.amount)) } // Debit: Expense accounts for (const [accountNumber, amount] of expenseByAccount) { lines.push({ account_number: accountNumber, description: accountNumber, debit: amount, credit: 0, }) } // Per-line effective VAT — manual override wins over computed amount × rate. // The engine reads stored vat_amount; the preview must reflect the same. const itemVat = (item: ReviewLineItem) => item.vat_amount != null ? Math.round(item.vat_amount * 100) / 100 : Math.round(item.amount * item.vat_rate * 100) / 100 if (reverseCharge) { // Reverse charge: the supplier charges no VAT, so the buyer self-assesses at // the Swedish statutory rate (resolveReverseChargeRate — 25% huvudregel // default, or the per-item reverse_charge_rate). We book BOTH the fiktiv-moms // pair (2645/2647 + 2614/2624/2634) AND the basbeloppsrader (44xx/45xx + // 4598), exactly as the engine does, so this preview matches the saved // verifikat. ML 16 kap requires both sides reported; silent netting is // prohibited (Skatteverket felkod FK004). Driving off the resolved rate (not // item.vat_rate) is what makes a 0%-rate RC line book its VAT at all. const isDomesticRC = supplierType === 'swedish_business' const inputAccount = isDomesticRC ? '2647' : '2645' const rcSupplierType: 'eu_business' | 'non_eu_business' | 'swedish_business' = supplierType === 'non_eu_business' || supplierType === 'swedish_business' ? supplierType : 'eu_business' // Base per self-assessed rate, plus the non-basis-account portion that needs // parallel basbeloppsrader (items booked straight to a 44xx/45xx basis // account already populate ruta 20-24 via the expense line, so they're // excluded there to avoid double-counting). const baseByRate = new Map() const nonBasisBaseByRate = new Map() for (const item of items) { const rate = resolveReverseChargeRate(item) const sek = toSek(item.amount) baseByRate.set(rate, (baseByRate.get(rate) || 0) + sek) if (!isReverseChargeBasisAccount(item.account_number)) { nonBasisBaseByRate.set(rate, (nonBasisBaseByRate.get(rate) || 0) + sek) } } for (const [rate, netAmount] of baseByRate) { if (netAmount <= 0) continue const fiktivVat = Math.round(netAmount * rate * 100) / 100 const outputAccount = getOutputVatAccount(rate) lines.push({ account_number: inputAccount, description: inputAccount, debit: fiktivVat, credit: 0, }) lines.push({ account_number: outputAccount, description: outputAccount, debit: 0, credit: fiktivVat, }) const nonBasisBase = nonBasisBaseByRate.get(rate) || 0 if (nonBasisBase > 0) { for (const bl of generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)) { lines.push({ account_number: bl.account_number, description: bl.line_description ?? bl.account_number, debit: bl.debit_amount, credit: bl.credit_amount, }) } } } // Credit: 2440 at subtotal (no real VAT for reverse charge) lines.push({ account_number: '2440', description: 'Leverantörsskulder', debit: 0, credit: toSek(subtotal), }) } else { if (totalVat > 0) { // Sum per-rate using effective (manual-or-computed) VAT, so the preview // matches what groupVatByRate will write to 2641 server-side. const vatByRate = new Map() for (const item of items) { const v = itemVat(item) if (v > 0) { vatByRate.set(item.vat_rate, (vatByRate.get(item.vat_rate) || 0) + v) } } for (const [, vat] of vatByRate) { lines.push({ account_number: '2641', description: 'Ingående moms', debit: toSek(vat), credit: 0, }) } } // Credit: 2440 at total incl. VAT lines.push({ account_number: '2440', description: 'Leverantörsskulder', debit: 0, credit: toSek(total), }) } return lines } export function SupplierInvoiceReviewContent({ supplier, invoiceNumber, invoiceDate, dueDate, deliveryDate, currency, exchangeRate, reverseCharge, paymentReference, items, subtotal, totalVat, total, }: SupplierInvoiceReviewContentProps) { const t = useTranslations('supplier_invoice_editor') const parsedRate = exchangeRate ? parseFloat(exchangeRate) : NaN const fxRate = currency !== 'SEK' && Number.isFinite(parsedRate) && parsedRate > 0 ? parsedRate : 1 const journalLines = buildJournalPreview(items, subtotal, totalVat, total, reverseCharge, supplier.supplier_type, fxRate) const totalDebit = journalLines.reduce((sum, l) => sum + l.debit, 0) const totalCredit = journalLines.reduce((sum, l) => sum + l.credit, 0) const showingSek = fxRate !== 1 const ACCOUNT_LABELS: Record = { '2440': t('account_2440'), '2641': t('account_2641'), '2645': t('account_2645'), '2647': t('account_2647'), '2614': t('account_2614'), '2624': t('account_2624'), '2634': t('account_2634'), '1710': t('account_1710'), '1720': t('account_1720'), '1730': t('account_1730'), '1740': t('account_1740'), '1790': t('account_1790'), } return (
{/* Supplier info */}

{supplier.name}

{t('review_invoice_number_label', { number: invoiceNumber })}

{reverseCharge && ( {t('reverse_charge_badge')} )} {currency !== 'SEK' && ( {currency} {exchangeRate && t('review_currency_rate_suffix', { rate: exchangeRate })} )}
{/* Dates */}
{t('invoice_date_label')}

{invoiceDate}

{t('due_date_label')}

{dueDate}

{deliveryDate && (
{t('delivery_date_label')}

{deliveryDate}

)}
{/* Line items — table on desktop, cards on mobile */}
{items.map((item, index) => { // For reverse charge the supplier charges 0%, so show the // self-assessed rate/amount the buyer books (matches the voucher // preview below). Manual vat_amount overrides only apply to // ordinary deductible VAT, never to RC self-assessment. const displayRate = reverseCharge ? resolveReverseChargeRate(item) : item.vat_rate const vatAmount = reverseCharge ? Math.round(item.amount * displayRate * 100) / 100 : item.vat_amount != null ? Math.round(item.vat_amount * 100) / 100 : Math.round(item.amount * item.vat_rate * 100) / 100 return ( ) })}
{t('col_account')} {t('col_description')} {t('col_amount')} {t('col_vat_rate')} {t('col_vat')}
{item.description} {itemHasAccrual(item) && (

{t('review_accrual_line_info', { from: accrualMonth(item.accrual_period_start!), to: accrualMonth(item.accrual_period_end!), })}

)}
{formatAmount(item.amount)} {Math.round(displayRate * 100)}% {formatAmount(vatAmount)}
{items.map((item, index) => { const displayRate = reverseCharge ? resolveReverseChargeRate(item) : item.vat_rate const vatAmount = reverseCharge ? Math.round(item.amount * displayRate * 100) / 100 : item.vat_amount != null ? Math.round(item.vat_amount * 100) / 100 : Math.round(item.amount * item.vat_rate * 100) / 100 return (

{item.description}

{itemHasAccrual(item) && (

{t('review_accrual_line_info', { from: accrualMonth(item.accrual_period_start!), to: accrualMonth(item.accrual_period_end!), })}

)}
{formatAmount(item.amount)} kr {t('review_vat_inline', { rate: Math.round(displayRate * 100), amount: formatAmount(vatAmount) })}
) })}
{/* Totals */}
{t('net_excl_vat')} {formatCurrency(subtotal, currency)}
{reverseCharge ? t('vat_reverse_charge') : t('vat_label_short')} {formatCurrency(totalVat, currency)}
{t('total_label')} {formatCurrency(total, currency)}
{currency !== 'SEK' && exchangeRate && (
{t('review_sek_amount_at_rate', { rate: exchangeRate })} {formatCurrency(total * parseFloat(exchangeRate))}
)}
{/* Verifikation preview */}

{t('review_voucher_preview_title')} {showingSek && ( {t('review_voucher_in_sek_suffix')} )}

{journalLines.map((line, index) => ( ))}
{t('col_account')} {t('col_description')} {t('col_debit')} {t('col_credit')}
{ACCOUNT_LABELS[line.account_number] || line.description} {line.debit > 0 ? formatAmount(line.debit) : ''} {line.credit > 0 ? formatAmount(line.credit) : ''}
{t('sum_label')} {formatAmount(totalDebit)} {formatAmount(totalCredit)}
{journalLines.map((line, index) => (
{ACCOUNT_LABELS[line.account_number] || line.description}
{line.debit > 0 ? t('debit_short', { amount: formatAmount(line.debit) }) : t('credit_short', { amount: formatAmount(line.credit) })}
))}
{t('sum_label')} {t('debit_credit_short', { debit: formatAmount(totalDebit), credit: formatAmount(totalCredit) })}
{/* Payment reference */} {paymentReference && (

{t('review_payment_reference_inline', { reference: paymentReference })}

)}
) }