Files
accounted/components/suppliers/SupplierInvoiceReviewContent.tsx
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

509 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 { formatAmount, formatCurrency, formatDate } from '@/lib/utils'
import {
resolveReverseChargeRate,
isReverseChargeBasisAccount,
generateReverseChargeBasisLines,
generateReverseChargeLines,
} from '@/lib/bookkeeping/vat-entries'
import { generateSlpLines, isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { buildSupplierDescription } from '@/lib/bookkeeping/supplier-invoice-description'
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
// Särskild löneskatt på pensionskostnader: previews the self-balancing
// 7533 D / 2514 K pair the engine injects for flagged 741x lines.
apply_slp?: boolean
// 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
}
interface JournalPreviewLine {
account_number: string
description: string
debit: number
credit: number
}
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,
// The verifikat description the engine will write to EVERY line of this
// entry (createSupplierInvoiceRegistrationEntry builds one `desc` and reuses
// it). The preview showed the bare account number here instead, so a user
// reviewing "Verifikation som bokförs" saw "5615" where the books would say
// "Leverantörsfaktura 123, ACME AB".
desc: string,
): 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<string, number>()
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: desc,
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
// Särskild löneskatt på pensionskostnader: same self-balancing pair the
// engine injects (7533 D / 2514 K at 24.26 % of flagged 741x lines), via
// the same generator, so this preview matches the saved verifikat. The
// pair nets to zero and never moves the 2440 credit below.
const slpBase = items.reduce(
(sum, item) =>
item.apply_slp && isSlpPensionAccount(item.account_number)
? sum + toSek(item.amount)
: sum,
0,
)
const slpPreviewLines: JournalPreviewLine[] = generateSlpLines(slpBase).map((sl) => ({
account_number: sl.account_number,
description: sl.line_description ?? sl.account_number,
debit: sl.debit_amount,
credit: sl.credit_amount,
}))
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 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<number, number>()
const nonBasisBaseByRate = new Map<number, number>()
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
// Same generator the engine calls, so the account pair AND the
// "Fiktiv in-/utgående moms" wording come from one place instead of
// being re-derived here (they used to render as bare account numbers).
for (const rcLine of generateReverseChargeLines(netAmount, rate, isDomesticRC)) {
lines.push({
account_number: rcLine.account_number,
description: rcLine.line_description ?? rcLine.account_number,
debit: rcLine.debit_amount,
credit: rcLine.credit_amount,
})
}
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,
})
}
}
}
lines.push(...slpPreviewLines)
// Credit: 2440 at subtotal (no real VAT for reverse charge)
lines.push({
account_number: '2440',
description: desc,
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<number, number>()
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 [rate, vat] of vatByRate) {
lines.push({
account_number: '2641',
description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
debit: toSek(vat),
credit: 0,
})
}
}
lines.push(...slpPreviewLines)
// Credit: 2440 at total incl. VAT
lines.push({
account_number: '2440',
description: desc,
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
// The description the engine will stamp on every line of this verifikat.
// The ankomstnummer suffix the backend appends is deliberately absent: it is
// assigned on save, so it does not exist yet at preview time. Everything
// before it is byte-identical to what gets posted.
const voucherDescription = buildSupplierDescription(
'Leverantörsfaktura',
invoiceNumber,
supplier.name,
)
const journalLines = buildJournalPreview(
items,
subtotal,
totalVat,
total,
reverseCharge,
supplier.supplier_type,
fxRate,
voucherDescription,
)
const totalDebit = journalLines.reduce((sum, l) => sum + l.debit, 0)
const totalCredit = journalLines.reduce((sum, l) => sum + l.credit, 0)
const showingSek = fxRate !== 1
// No account-label lookup any more: the BESKRIVNING column shows the
// line_description that will actually be posted. A hardcoded label map
// covering 11 accounts meant the column silently mixed "account label" (for
// those) with "raw account number" (for every expense account), and neither
// was the posted text. The account's own name stays available on the
// AccountNumber hover card.
return (
<div className="space-y-4">
{/* Supplier info */}
<div className="bg-muted rounded-lg p-3 sm:p-4 flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="min-w-0">
<p className="font-medium text-base truncate">{supplier.name}</p>
<p className="text-sm text-muted-foreground">{t('review_invoice_number_label', { number: invoiceNumber })}</p>
</div>
<div className="flex flex-wrap gap-1.5 sm:gap-2 shrink-0">
{reverseCharge && (
<Badge variant="warning">
{t('reverse_charge_badge')}
</Badge>
)}
{currency !== 'SEK' && (
<Badge variant="outline" className="text-sm">
{currency}
{exchangeRate && t('review_currency_rate_suffix', { rate: exchangeRate })}
</Badge>
)}
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 sm:gap-4 text-sm">
<div>
<span className="text-muted-foreground">{t('invoice_date_label')}</span>
<p className="font-medium">{formatDate(invoiceDate)}</p>
</div>
<div>
<span className="text-muted-foreground">{t('due_date_label')}</span>
<p className="font-medium">{formatDate(dueDate)}</p>
</div>
{deliveryDate && (
<div>
<span className="text-muted-foreground">{t('delivery_date_label')}</span>
<p className="font-medium">{formatDate(deliveryDate)}</p>
</div>
)}
</div>
{/* Line items: table on desktop, cards on mobile */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-20">{t('col_account')}</th>
<th className="py-2">{t('col_description')}</th>
<th className="py-2 w-28 text-right">{t('col_amount')}</th>
<th className="py-2 w-16 text-right">{t('col_vat_rate')}</th>
<th className="py-2 w-24 text-right">{t('col_vat')}</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={index} className="border-b last:border-0">
<td className="py-2">
<AccountNumber number={item.account_number} size="sm" />
</td>
<td className="py-2">
{item.description}
{itemHasAccrual(item) && (
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">
{t('review_accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
</span>
</p>
)}
</td>
<td className="py-2 text-right tabular-nums">{formatAmount(item.amount)}</td>
<td className="py-2 text-right">{Math.round(displayRate * 100)}%</td>
<td className="py-2 text-right tabular-nums">{formatAmount(vatAmount)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-2">
{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 (
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
<div className="flex items-center justify-between">
<p className="font-medium">{item.description}</p>
<AccountNumber number={item.account_number} size="sm" />
</div>
{itemHasAccrual(item) && (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">
{t('review_accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
</span>
</p>
)}
<div className="flex items-center justify-between text-muted-foreground">
<span>{formatAmount(item.amount)} kr</span>
<span className="text-xs">{t('review_vat_inline', { rate: Math.round(displayRate * 100), amount: formatAmount(vatAmount) })}</span>
</div>
</div>
)
})}
</div>
{/* Totals */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('net_excl_vat')}</span>
<span className="tabular-nums">{formatCurrency(subtotal, currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{reverseCharge ? t('vat_reverse_charge') : t('vat_label_short')}</span>
<span className="tabular-nums">{formatCurrency(totalVat, currency)}</span>
</div>
<Separator />
<div className="flex justify-between font-bold text-xl sm:text-2xl">
<span>{t('total_label')}</span>
<span className="tabular-nums">{formatCurrency(total, currency)}</span>
</div>
{currency !== 'SEK' && exchangeRate && (
<div className="flex justify-between text-muted-foreground">
<span>{t('review_sek_amount_at_rate', { rate: exchangeRate })}</span>
<span className="tabular-nums">{formatCurrency(total * parseFloat(exchangeRate))}</span>
</div>
)}
</div>
{/* Verifikation preview */}
<div className="bg-muted/50 border rounded-lg p-3 sm:p-4 space-y-2">
<p className="text-sm font-semibold text-muted-foreground">
{t('review_voucher_preview_title')}
{showingSek && (
<span className="ml-1.5 font-normal text-xs">{t('review_voucher_in_sek_suffix')}</span>
)}
</p>
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="text-left">
<th className="pb-1 w-16">{t('col_account')}</th>
<th className="pb-1">{t('col_description')}</th>
<th className="pb-1 w-24 text-right">{t('col_debit')}</th>
<th className="pb-1 w-24 text-right">{t('col_credit')}</th>
</tr>
</thead>
<tbody>
{journalLines.map((line, index) => (
<tr key={index} className="border-b border-dashed border-muted-foreground/20 last:border-0">
<td className="py-1">
<AccountNumber number={line.account_number} size="sm" />
</td>
<td className="py-1 text-xs">
{line.description}
</td>
<td className="py-1 text-right tabular-nums">
{line.debit > 0 ? formatAmount(line.debit) : ''}
</td>
<td className="py-1 text-right tabular-nums">
{line.credit > 0 ? formatAmount(line.credit) : ''}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t font-semibold">
<td className="pt-1" colSpan={2}>{t('sum_label')}</td>
<td className="pt-1 text-right tabular-nums">{formatAmount(totalDebit)}</td>
<td className="pt-1 text-right tabular-nums">{formatAmount(totalCredit)}</td>
</tr>
</tfoot>
</table>
</div>
<div className="sm:hidden space-y-1.5 text-sm">
{journalLines.map((line, index) => (
<div key={index} className="flex items-center justify-between py-1 border-b border-dashed border-muted-foreground/20 last:border-0">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<AccountNumber number={line.account_number} size="sm" />
<span className="text-xs text-muted-foreground truncate">
{line.description}
</span>
</div>
</div>
<span className="tabular-nums text-xs shrink-0 ml-2">
{line.debit > 0 ? t('debit_short', { amount: formatAmount(line.debit) }) : t('credit_short', { amount: formatAmount(line.credit) })}
</span>
</div>
))}
<div className="flex justify-between pt-1 border-t font-semibold text-xs tabular-nums">
<span>{t('sum_label')}</span>
<span>{t('debit_credit_short', { debit: formatAmount(totalDebit), credit: formatAmount(totalCredit) })}</span>
</div>
</div>
</div>
{/* Payment reference */}
{paymentReference && (
<div className="border-t pt-3 text-sm text-muted-foreground">
<p>{t('review_payment_reference_inline', { reference: paymentReference })}</p>
</div>
)}
</div>
)
}