'use client' import { Fragment, useState, useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { useForm, Controller, useFieldArray } from 'react-hook-form' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import AiFilledIndicator from '@/components/ui/ai-filled-indicator' import { Textarea } from '@/components/ui/textarea' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions' import { getErrorMessage } from '@/lib/errors/get-error-message' import { cn, formatCurrency } from '@/lib/utils' import { getDisplayTotal } from '@/lib/invoices/rounding' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import { useCanWrite } from '@/lib/hooks/use-can-write' import BankTransactionPicker from '@/components/transactions/BankTransactionPicker' import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl' import LineDimensionFields from '@/components/dimensions/LineDimensionFields' import DocumentUploadZone, { type UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions' import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute' import { LEGAL_VAT_RATES, findIllegalVatRateRow, findReverseChargeAccountWarningRows, findUnflaggedForeignZeroVatRows, } from '@/lib/vat/supplier-invoice-line-checks' import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, AlertTriangle, MessageCircle, Link2, CalendarClock, Tags, Paperclip } from 'lucide-react' import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult, FiscalPeriod } from '@/types' interface LineItem { description: string amount: number account_number: string vat_rate: number // Self-assessed VAT rate for omvänd skattskyldighet (0.25/0.12/0.06). Only // meaningful when reverse_charge is on; the line's vat_rate is then 0. reverse_charge_rate?: number // Periodisering (förutbetald kostnad): both dates + 17xx interim account. // Present only while the row's periodisering panel is active. accrual_period_start?: string accrual_period_end?: string accrual_balance_account?: string // Per-item dimensions bag ({sie_dim_no: code}, dimensions PR7). Defined // (possibly empty) while the row's dimensions panel is open; the server // merges it over the invoice's default_dimensions at booking time. dimensions?: Record } // The existing invoice surfaced on a duplicate-number conflict, used to drive // the resolution dialog (open it / uncredit-and-retry). interface ExistingSupplierInvoice { id: string supplier_invoice_number: string status: string credit_note_id: string | null } // Canonical create/convert response. On failure `error` is the structured // envelope's inner object ({ code, message, details }); a few legacy convert // paths still return a flat string, so accept both. interface CreateResult { data?: { id: string; arrival_number: number } warnings?: Array<{ code: string; message: string }> error?: | string | { code?: string message?: string message_en?: string details?: { existing?: ExistingSupplierInvoice | null } } } interface FormData { supplier_id: string supplier_invoice_number: string invoice_date: string due_date: string delivery_date: string currency: string exchange_rate: string reverse_charge: boolean payment_reference: string notes: string paid_with_private_funds: boolean items: LineItem[] } interface NewSupplierForm { name: string supplier_type: string org_number: string vat_number: string address_line1: string bankgiro: string plusgiro: string default_expense_account: string } function RequiredMark() { return } function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } function inferVatTreatment(items: LineItem[], reverseCharge: boolean): VatTreatment { if (reverseCharge) return 'reverse_charge' const rates = new Set(items.map((i) => i.vat_rate)) if (rates.size === 1) { const rate = rates.values().next().value! if (rate === 0.25) return 'standard_25' if (rate === 0.12) return 'reduced_12' if (rate === 0.06) return 'reduced_6' if (rate === 0) return 'exempt' } return 'standard_25' } // AI returns VAT as integer percent (25, 12, 6, 0). The form stores decimals. function vatRateFromAi(rate: number | null | undefined): number { if (rate == null) return 0.25 if (rate === 25) return 0.25 if (rate === 12) return 0.12 if (rate === 6) return 0.06 return 0 } function rateToPctString(rate: number): string { const pct = Math.round(rate * 10000) / 100 return Number.isFinite(pct) ? String(pct) : '' } function VatRateCell({ value, onChange }: { value: number; onChange: (v: number) => void }) { const t = useTranslations('supplier_invoice_editor') const inputRef = useRef(null) // Local draft so the user can type "12," or "12." mid-keystroke without the // controlled input snapping back to a parsed integer. const [draft, setDraft] = useState(() => rateToPctString(value)) // Re-sync from form value only when the field isn't focused: keeps AI // prefill / supplier defaults / dropdown picks flowing in without clobbering // active typing. useEffect(() => { if (document.activeElement !== inputRef.current) { setDraft(rateToPctString(value)) } }, [value]) return (
e.currentTarget.select()} onBlur={() => setDraft(rateToPctString(value))} onChange={(e) => { const raw = e.target.value // Strict whitelist: digits with at most one decimal separator. // Blocks "2-22", "100-2", "1.2.3", letters, signs: the keystroke // is dropped before reaching the draft. if (raw !== '' && !/^\d*[.,]?\d*$/.test(raw)) return const normalized = raw.replace(',', '.') if (normalized === '' || normalized === '.') { setDraft(raw) onChange(0) return } const parsed = parseFloat(normalized) if (!Number.isFinite(parsed)) { setDraft(raw) return } const clamped = Math.min(100, Math.max(0, parsed)) // Snap the draft back when the parsed value falls outside [0, 100] // so the input can never display a rate the form won't apply. setDraft(clamped === parsed ? raw : String(clamped)) onChange(clamped / 100) }} className="text-right tabular-nums pr-6" aria-label={t('col_vat_rate')} /> %
{LEGAL_VAT_RATES.map((preset) => ( onChange(preset)} className="justify-end tabular-nums" > {Math.round(preset * 100)} % ))}
) } // Self-assessment rate picker shown in place of the Momssats cell when an // invoice is reverse charge. The supplier charges no VAT (the line vat_rate is // 0); this is the Swedish statutory rate the buyer self-assesses at: 25% // huvudregeln for EU services, 12%/6% for reduced-rated services (ML 6 kap 34 §). function RcRateSelect({ value, onChange }: { value: number; onChange: (v: number) => void }) { const t = useTranslations('supplier_invoice_editor') return ( ) } const EMPTY_NEW_SUPPLIER: NewSupplierForm = { name: '', supplier_type: 'swedish_business', org_number: '', vat_number: '', address_line1: '', bankgiro: '', plusgiro: '', default_expense_account: '', } export interface NewSupplierInvoiceFormProps { /** Invoice-inbox item to convert; prefills the form from its AI extraction. */ inboxItemId?: string | null /** * Render without page chrome (back button + h1) for use inside * NewSupplierInvoiceDialog: the same convention as JournalEntryForm's * `bare`. */ bare?: boolean /** * Called after a successful create instead of the standalone page's * navigation, so a dialog host can close itself and refresh in place. * Receives the new invoice id when the natural next step is its detail * page, undefined when the flow is fully done (e.g. expense registered). */ onCreated?: (invoiceId?: string) => void /** Called by the Avbryt button instead of navigating (dialog mode). */ onCancel?: () => void } export default function NewSupplierInvoiceForm({ inboxItemId = null, bare = false, onCreated, onCancel, }: NewSupplierInvoiceFormProps = {}) { const router = useRouter() const { canWrite } = useCanWrite() const { toast } = useToast() const t = useTranslations('supplier_invoice_editor') const ta = useTranslations('accruals') // When opened from an invoice-inbox item, every redirect should land the // user back in the inbox so they can pick the next document. Outside the // inbox flow, preserve the original behavior (detail page when we have an // invoice id, otherwise the list). const afterCreate = (invoiceId?: string) => inboxItemId ? '/e/general/invoice-inbox' : invoiceId ? `/supplier-invoices/${invoiceId}` : '/supplier-invoices' // After a successful create: hand control back to the host when mounted // with onCreated (dialog mode), otherwise navigate like the old standalone // page did. const finishCreate = (invoiceId?: string) => { createFinishedRef.current = true if (onCreated) onCreated(invoiceId) else router.push(afterCreate(invoiceId)) } const [suppliers, setSuppliers] = useState([]) const [suppliersLoaded, setSuppliersLoaded] = useState(false) const [accounts, setAccounts] = useState([]) const [entityType, setEntityType] = useState('enskild_firma') const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual') // Öresavrundning is display-only; defaults to the company-wide setting and is // overridable per invoice via the toggle in the totals section. const [oreRounding, setOreRounding] = useState(true) // Dimension tagging (kostnadsställe/projekt). Affordances render only when // company_settings.dimensions_enabled: the same UI-visibility gate as // JournalEntryForm. defaultDims is the invoice-level default bag; per-item // bags live on the form's items and merge over it server-side. const [dimensionsEnabled, setDimensionsEnabled] = useState(false) const [defaultDims, setDefaultDims] = useState>({}) const [periods, setPeriods] = useState([]) const [periodsLoaded, setPeriodsLoaded] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [showReview, setShowReview] = useState(false) const [pendingData, setPendingData] = useState(null) const [showNewSupplier, setShowNewSupplier] = useState(false) const [isCreatingSupplier, setIsCreatingSupplier] = useState(false) const [pendingSupplierSelect, setPendingSupplierSelect] = useState(null) const [advancedOpen, setAdvancedOpen] = useState(false) const [newSupplier, setNewSupplier] = useState(EMPTY_NEW_SUPPLIER) const [documentFiles, setDocumentFiles] = useState([]) const documentFilesRef = useRef([]) const createFinishedRef = useRef(false) // Inbox/AI state const [extractedData, setExtractedData] = useState(null) const [originalExtracted, setOriginalExtracted] = useState(null) const [hasMatchedSupplier, setHasMatchedSupplier] = useState(false) const [isLoadingInbox, setIsLoadingInbox] = useState(!!inboxItemId) const [hasPrefilled, setHasPrefilled] = useState(false) // Match-on-create state const [showBankPicker, setShowBankPicker] = useState(false) const [pendingTransactionId, setPendingTransactionId] = useState(null) // The button's onClick and the form's onSubmit run in the same React event // batch, so a `useState`-backed submitMode would still hold the previous // render's value when onSubmit reads it. A ref bridges the two synchronous // handlers; the matching state mirror only drives the review-dialog UI. const submitModeRef = useRef<'register' | 'register_and_match'>('register') // Conflict state for duplicate-supplier-invoice-number const [conflict, setConflict] = useState<{ message: string existing: ExistingSupplierInvoice | null } | null>(null) const [isResolvingConflict, setIsResolvingConflict] = useState(false) const invoiceNumberInputRef = useRef(null) const { register, control, handleSubmit, watch, setValue, getValues, reset, formState: { isDirty } } = useForm({ defaultValues: { supplier_id: '', supplier_invoice_number: '', invoice_date: new Date().toISOString().split('T')[0], due_date: '', delivery_date: '', currency: 'SEK', exchange_rate: '', reverse_charge: false, payment_reference: '', notes: '', paid_with_private_funds: false, // account_number is deliberately empty: a silent prefilled expense // account (the old '5010' Lokalhyra seed) produced legally wrong // verifikat whenever the user didn't notice it. An explicit choice is // required; the supplier's default_expense_account fills it when set. items: [{ description: '', amount: 0, account_number: '', vat_rate: 0.25, reverse_charge_rate: 0.25 }], }, }) useUnsavedChanges(isDirty) useEffect(() => { documentFilesRef.current = documentFiles }, [documentFiles]) useEffect(() => () => { if (inboxItemId || createFinishedRef.current) return for (const file of documentFilesRef.current) { if (file.status === 'uploaded' && file.id) { void fetch(`/api/documents/${file.id}`, { method: 'DELETE', keepalive: true }) } } }, [inboxItemId]) const { fields, append, remove, replace } = useFieldArray({ control, name: 'items' }) const watchedItems = watch('items') const watchedSupplierId = watch('supplier_id') const watchedCurrency = watch('currency') const watchedExchangeRate = watch('exchange_rate') const watchedPaidPrivately = watch('paid_with_private_funds') const watchedReverseCharge = watch('reverse_charge') // Watched values used to decide whether the AI-filled indicator should // still be visible. Once the user edits a field, its value no longer // matches what the extractor wrote, and the dot fades out. const watchedInvoiceNumber = watch('supplier_invoice_number') const watchedInvoiceDate = watch('invoice_date') const watchedDueDate = watch('due_date') const watchedPaymentReference = watch('payment_reference') const documentUploadInProgress = documentFiles.some((file) => file.status === 'uploading') const documentUploadFailed = documentFiles.some((file) => file.status === 'error') const uploadedDocumentId = documentFiles.find((file) => file.status === 'uploaded')?.id // Returns true when the field currently matches whatever the AI wrote // when the form first loaded. Edits diverge it, hiding the dot. function stillFromAi(value: string | null | undefined, original: string | null | undefined): boolean { if (!original) return false return (value ?? '') === (original ?? '') } const aiFlags = { invoiceNumber: stillFromAi(watchedInvoiceNumber, originalExtracted?.invoice?.invoiceNumber ?? null), invoiceDate: stillFromAi(watchedInvoiceDate, originalExtracted?.invoice?.invoiceDate ?? null), dueDate: stillFromAi(watchedDueDate, originalExtracted?.invoice?.dueDate ?? null), paymentReference: stillFromAi( watchedPaymentReference, originalExtracted?.invoice?.paymentReference ?? null, ), } const isEF = entityType === 'enskild_firma' // Out-of-period guard (mirrors the manual voucher form). A registration JE is // only posted at registration time under the accrual method or when the // invoice is marked paid privately: cash method books at payment, so an // out-of-period date is fine there and we stay quiet. periodsLoaded gates the // warning so it never flashes before the fiscal periods have been fetched. const willBookAtRegistration = accountingMethod === 'accrual' || watchedPaidPrivately const invoiceDateOutsidePeriod = periodsLoaded && !!watchedInvoiceDate && !periods.some((p) => watchedInvoiceDate >= p.period_start && watchedInvoiceDate <= p.period_end) const showNoPeriodWarning = willBookAtRegistration && invoiceDateOutsidePeriod // Advisory nudge (#863 item 2): reverse charge purchases are normally booked // on cost accounts (4xxx/5xxx), so flag lines sitting on a class 1 or 6 // account while omvand skattskyldighet is on. Never blocks submission: class // 6 has legitimate reverse charge uses (e.g. 6540 for EU cloud services). const rcAccountWarningRows = watchedReverseCharge ? findReverseChargeAccountWarningRows(watchedItems ?? []) : [] // Advisory nudge (#1042): a foreign supplier charging no Swedish VAT is // normally omvand skattskyldighet. With the switch off no 26x4 leg and no // 44xx/45xx basis is booked, so ruta 20-24, 30-32 and 48 stay empty. Silent // for Swedish suppliers, where 0 % is a genuine exemption. Never blocks: // a non-EU goods purchase cleared at customs is legitimately 0 % without // reverse charge, and forcing the switch there would book a wrong verifikat. const foreignZeroVatRows = findUnflaggedForeignZeroVatRows( watchedItems ?? [], watchedReverseCharge, suppliers.find((s) => s.id === watchedSupplierId)?.supplier_type, ) useEffect(() => { fetchSuppliers() fetchAccounts() fetchEntityType() fetchPeriods() }, []) // One-shot: load inbox item and prefill form. Runs after suppliers are // loaded so we can resolve matched_supplier_id to a real picker value. // Gate on `suppliersLoaded`, not `suppliers.length > 0`: otherwise the // effect never fires for users who haven't booked a supplier yet and // the "Laddar uppgifter från inkorgen…" spinner sticks forever. useEffect(() => { if (!inboxItemId || hasPrefilled || !suppliersLoaded) return let cancelled = false ;(async () => { try { const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${inboxItemId}`) const json = await res.json() if (cancelled) return if (!res.ok) { toast({ title: t('inbox_load_failed_title'), description: json?.error || t('inbox_load_failed_description'), variant: 'destructive', }) setIsLoadingInbox(false) return } const item = json.data as { id: string extracted_data: InvoiceExtractionResult | null matched_supplier_id: string | null document_id: string | null } const extracted = item.extracted_data if (!extracted) { setIsLoadingInbox(false) setHasPrefilled(true) return } setExtractedData(extracted) setOriginalExtracted(extracted) // Supplier if (item.matched_supplier_id && suppliers.find((s) => s.id === item.matched_supplier_id)) { setValue('supplier_id', item.matched_supplier_id) setHasMatchedSupplier(true) } // Scalar invoice fields if (extracted.invoice?.invoiceNumber) { setValue('supplier_invoice_number', extracted.invoice.invoiceNumber) } if (extracted.invoice?.invoiceDate) { setValue('invoice_date', extracted.invoice.invoiceDate) } if (extracted.invoice?.dueDate) { setValue('due_date', extracted.invoice.dueDate) } if (extracted.invoice?.paymentReference) { setValue('payment_reference', extracted.invoice.paymentReference) } if (extracted.invoice?.currency) { setValue('currency', extracted.invoice.currency) } // Line items: keep the single empty default if AI returned nothing, // otherwise replace it with the extracted lines. When the document // states a service window of 2+ calendar months (insurance period, // license term), pre-fill periodisering on every positive line: the // user sees the panel and can remove it before booking. if (extracted.lineItems && extracted.lineItems.length > 0) { // AI-extracted values are untrusted input: only accept strict // ISO-8601 dates before they reach form state (and later the API). const isIsoDate = (v: unknown): v is string => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) const spsRaw = extracted.invoice?.servicePeriodStart const speRaw = extracted.invoice?.servicePeriodEnd const sps = isIsoDate(spsRaw) ? spsRaw : null const spe = isIsoDate(speRaw) ? speRaw : null let prefillAccrual = false if (sps && spe && spe >= sps) { try { prefillAccrual = countCalendarMonths(sps, spe) >= 2 } catch { prefillAccrual = false } } replace( extracted.lineItems.map((li) => { const amount = typeof li.lineTotal === 'number' ? li.lineTotal : 0 const withAccrual = prefillAccrual && amount > 0 return { description: li.description || '', amount, // Extraction never suggests accounts (forcibly nulled at parse // time) and a silent default misbooks: leave empty so the user // (or the supplier default) makes the call. account_number: '', vat_rate: vatRateFromAi(li.vatRate), accrual_period_start: withAccrual ? (sps as string) : undefined, accrual_period_end: withAccrual ? (spe as string) : undefined, // No account yet → generic 1790; toggleAccrual re-suggests the // same way once the user picks one. accrual_balance_account: withAccrual ? suggestBalanceAccount('expense', '') : undefined, } }), ) } // Treat the AI prefill as the new baseline: otherwise the unsaved- // changes prompt fires the moment the user navigates away, even if // they didn't touch anything. reset(getValues()) setHasPrefilled(true) } catch (err) { if (cancelled) return toast({ title: t('inbox_load_failed_title'), description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'), variant: 'destructive', }) } finally { if (!cancelled) setIsLoadingInbox(false) } })() return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [inboxItemId, suppliersLoaded, suppliers]) // Auto-fill due date and defaults when supplier is selected: but never // overwrite a value the AI already filled in for us. useEffect(() => { if (!watchedSupplierId) return const supplier = suppliers.find((s) => s.id === watchedSupplierId) if (!supplier) return const invoiceDate = watch('invoice_date') const currentDue = watch('due_date') if (invoiceDate && !currentDue) { const due = new Date(invoiceDate) due.setDate(due.getDate() + supplier.default_payment_terms) setValue('due_date', due.toISOString().split('T')[0]) } if (supplier.default_expense_account && fields.length > 0) { // Fill every row the user hasn't assigned yet: an empty account is the // only signal needed (rows start empty by design, no seeded default). const items = getValues('items') items.forEach((row, i) => { if (!row.account_number) { setValue(`items.${i}.account_number`, supplier.default_expense_account!) } }) } if (supplier.default_currency && watch('currency') === 'SEK') { setValue('currency', supplier.default_currency) } if (supplier.supplier_type === 'eu_business') { setValue('reverse_charge', true) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [watchedSupplierId, suppliers]) // Auto-fetch Riksbanken exchange rate when currency switches to non-SEK and // the user hasn't typed a custom rate yet. Re-fetches when the invoice // date changes too. Never overwrites a user-entered rate. Reuses the // watchedInvoiceDate declared above for the AI-filled-indicator flag. // The "user has manually edited the rate" flag is scoped *per currency*. // Switching from EUR (rate 11.8 edited by hand) to USD must re-fetch: the // EUR rate is meaningless for a USD invoice. Tracking last-fetched currency // lets us reset the touched flag on a currency switch while still honoring // a manual edit when only the invoice date changes within the same currency. const userTouchedRateRef = useRef(false) const lastFxCurrencyRef = useRef(null) useEffect(() => { if (watchedCurrency === 'SEK') { setValue('exchange_rate', '') userTouchedRateRef.current = false lastFxCurrencyRef.current = null return } if (lastFxCurrencyRef.current !== watchedCurrency) { // Currency switched: drop the previous currency's manual-edit flag. userTouchedRateRef.current = false lastFxCurrencyRef.current = watchedCurrency } if (userTouchedRateRef.current) return let cancelled = false ;(async () => { try { const url = `/api/currency/rate?currency=${watchedCurrency}${ watchedInvoiceDate ? `&date=${watchedInvoiceDate}` : '' }` const res = await fetch(url) if (!res.ok) return const { data } = await res.json() if (cancelled || !data?.rate) return // Don't clobber a value the user typed while we were fetching. if (userTouchedRateRef.current) return setValue('exchange_rate', String(Math.round(data.rate * 10000) / 10000)) } catch { // Non-critical: user can type the rate manually. } })() return () => { cancelled = true } }, [watchedCurrency, watchedInvoiceDate, setValue]) // Auto-select newly created supplier once it shows up in the list useEffect(() => { if (pendingSupplierSelect && suppliers.find((s) => s.id === pendingSupplierSelect)) { setValue('supplier_id', pendingSupplierSelect, { shouldDirty: true, shouldValidate: true }) setPendingSupplierSelect(null) } }, [suppliers, pendingSupplierSelect, setValue]) async function fetchSuppliers() { try { const res = await fetch('/api/suppliers') const { data } = await res.json() setSuppliers(data || []) } finally { setSuppliersLoaded(true) } } async function fetchAccounts() { const res = await fetch('/api/bookkeeping/accounts') const { data } = await res.json() setAccounts(data || []) } async function fetchEntityType() { try { const res = await fetch('/api/settings') const { data } = await res.json() if (data?.entity_type) setEntityType(data.entity_type) // Cash method books at payment, not registration: drives whether the // out-of-period warning is relevant (see willBookAtRegistration below). if (data?.accounting_method === 'cash' || data?.accounting_method === 'accrual') { setAccountingMethod(data.accounting_method) } if (typeof data?.ore_rounding === 'boolean') setOreRounding(data.ore_rounding) setDimensionsEnabled(data?.dimensions_enabled === true) } catch { // Default to enskild_firma / accrual, dimension affordances hidden } } async function fetchPeriods() { try { const res = await fetch('/api/bookkeeping/fiscal-periods') const { data } = await res.json() setPeriods(data || []) } catch { // Non-critical: the server still hard-blocks an out-of-period booking. } finally { setPeriodsLoaded(true) } } function handleAccountChange(index: number, accountNumber: string) { setValue(`items.${index}.account_number`, accountNumber) const currentDesc = watch(`items.${index}.description`) if (!currentDesc && accountNumber.length === 4) { const desc = getAccountDescription(accountNumber) if (desc) setValue(`items.${index}.description`, desc.name) } // Auto-fill the rad's moms from the konto's configured default (e.g. // öresavrundning 3740 = ingen moms), so a rounding line stops inheriting // the 25 % rad-default. Only when the konto carries an explicit default; // otherwise the user's current rate stands. Reverse charge uses its own // rate field, so leave that flow untouched. PostgREST serialises numeric // columns as strings, so coerce: strict === comparisons on vat_rate // (inferVatTreatment) expect a number. const acct = accounts.find((a) => a.account_number === accountNumber) const defaultRate = acct?.default_vat_rate == null ? null : Number(acct.default_vat_rate) if (!watchedReverseCharge && defaultRate != null && Number.isFinite(defaultRate)) { setValue(`items.${index}.vat_rate`, defaultRate, { shouldDirty: true }) } } // Periodisering per rad: kräver faktureringsmetoden; eget utlägg bokar // kostnaden direkt mot ägarkontot och kan inte periodiseras. Omvänd // skattskyldighet kan inte heller periodiseras: kostnadsraden utgör // momsunderlaget (ruta 20-32) och får inte flyttas till ett interimskonto. const canUseAccrual = accountingMethod === 'accrual' && !watchedPaidPrivately && !watchedReverseCharge // When reverse charge is switched on, clear any per-line periodisering so a // stale AI prefill (or fields set before the toggle) can never reach the // API, which rejects the combination with SI_CREATE_ACCRUAL_REVERSE_CHARGE. useEffect(() => { if (!watchedReverseCharge) return const items = getValues('items') ?? [] items.forEach((item, index) => { if ( item.accrual_period_start !== undefined || item.accrual_period_end !== undefined || item.accrual_balance_account !== undefined ) { setValue(`items.${index}.accrual_period_start`, undefined, { shouldDirty: true }) setValue(`items.${index}.accrual_period_end`, undefined, { shouldDirty: true }) setValue(`items.${index}.accrual_balance_account`, undefined, { shouldDirty: true }) } }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [watchedReverseCharge]) function isAccrualOpen(index: number): boolean { return watchedItems?.[index]?.accrual_balance_account != null } function toggleAccrual(index: number) { if (isAccrualOpen(index)) { setValue(`items.${index}.accrual_period_start`, undefined, { shouldDirty: true }) setValue(`items.${index}.accrual_period_end`, undefined, { shouldDirty: true }) setValue(`items.${index}.accrual_balance_account`, undefined, { shouldDirty: true }) } else { const account = watch(`items.${index}.account_number`) || '' setValue(`items.${index}.accrual_period_start`, watch('invoice_date') || '', { shouldDirty: true }) setValue(`items.${index}.accrual_period_end`, '', { shouldDirty: true }) setValue( `items.${index}.accrual_balance_account`, suggestBalanceAccount('expense', account), { shouldDirty: true }, ) } } // --- Dimension tagging (kostnadsställe/projekt, dimensions PR7) --- // Mirrors the accrual pattern: a defined (possibly empty) bag on the item // means the row's panel is open; closing clears the bag entirely so the // payload never carries a stale override. function setDefaultDimension(dimNo: string, code: string | null) { setDefaultDims((prev) => { const next = { ...prev } const trimmed = code?.trim() if (trimmed) next[dimNo] = trimmed else delete next[dimNo] return next }) } function isDimOpen(index: number): boolean { return watchedItems?.[index]?.dimensions != null } function toggleDimensions(index: number) { if (isDimOpen(index)) { setValue(`items.${index}.dimensions`, undefined, { shouldDirty: true }) } else { setValue(`items.${index}.dimensions`, {}, { shouldDirty: true }) } } function updateItemDimension(index: number, dimNo: string, code: string | null) { const current = { ...(getValues(`items.${index}.dimensions`) ?? {}) } const trimmed = code?.trim() if (trimmed) current[dimNo] = trimmed else delete current[dimNo] setValue(`items.${index}.dimensions`, current, { shouldDirty: true }) } // Compact display of the invoice default, e.g. "KS01 · P001" (dim-number order). const defaultDimsSummary = Object.entries(defaultDims) .filter(([, v]) => v) .sort(([a], [b]) => Number(a) - Number(b)) .map(([, v]) => v) .join(' · ') function renderDimensionsPanel(index: number) { const item = watchedItems?.[index] if (!item || item.dimensions == null) return null return (
updateItemDimension(index, dimNo, code)} inputClassName="h-8" />
{defaultDimsSummary && (

{t('row_dimensions_inherit_hint', { dims: defaultDimsSummary })}

)}
) } function renderAccrualPanel(index: number, idPrefix: string) { const item = watchedItems?.[index] if (!item || item.accrual_balance_account == null) return null return ( { setValue(`items.${index}.accrual_period_start`, next.start, { shouldDirty: true }) setValue(`items.${index}.accrual_period_end`, next.end, { shouldDirty: true }) setValue(`items.${index}.accrual_balance_account`, next.balanceAccount, { shouldDirty: true }) }} onRemove={() => toggleAccrual(index)} /> ) } const itemTotals = (watchedItems || []).map((item) => { const lineTotal = Math.round((item.amount || 0) * 100) / 100 // Reverse charge: VAT is self-assessed at reverse_charge_rate (25% default), // not the line's vat_rate (which is 0: the supplier charged nothing). const effectiveRate = watchedReverseCharge ? (item.reverse_charge_rate ?? 0.25) : (item.vat_rate || 0) const vatAmount = Math.round(lineTotal * effectiveRate * 100) / 100 return { lineTotal, vatAmount } }) const subtotal = itemTotals.reduce((sum, t) => sum + t.lineTotal, 0) const totalVat = itemTotals.reduce((sum, t) => sum + t.vatAmount, 0) // Reverse charge: supplier never invoices VAT, so it doesn't roll into the // payable total. The VAT is still accounted for via 2614 / 2645 in // bookkeeping: the line stays in the breakdown for transparency. const payableVat = watchedReverseCharge ? 0 : totalVat const total = Math.round((subtotal + payableVat) * 100) / 100 // Öresavrundning live preview: same helper as the detail page. Display-only; // the registered amount and the booked verifikat keep the exact öre. const displayRounding = getDisplayTotal( { total, currency: watchedCurrency || 'SEK', ore_rounding: oreRounding }, { ore_rounding: false }, ) // Show the AI-suggested supplier card when we have an inbox item, the AI // surfaced a supplier name, and we couldn't match it to an existing record. const showAISupplierHint = !!extractedData?.supplier?.name && !hasMatchedSupplier && !watchedSupplierId function openSupplierDialogPrefilled() { setNewSupplier({ name: extractedData?.supplier?.name || '', supplier_type: 'swedish_business', org_number: extractedData?.supplier?.orgNumber || '', vat_number: extractedData?.supplier?.vatNumber || '', address_line1: extractedData?.supplier?.address || '', bankgiro: extractedData?.supplier?.bankgiro || '', plusgiro: extractedData?.supplier?.plusgiro || '', default_expense_account: '', }) setShowNewSupplier(true) } function openSupplierDialogBlank() { setNewSupplier(EMPTY_NEW_SUPPLIER) setShowNewSupplier(true) } async function handleCreateSupplier() { if (!newSupplier.name.trim()) { toast({ title: t('name_missing_title'), description: t('name_missing_description'), variant: 'destructive' }) return } setIsCreatingSupplier(true) const payload: Record = { name: newSupplier.name, supplier_type: newSupplier.supplier_type, } if (newSupplier.org_number) payload.org_number = newSupplier.org_number if (newSupplier.vat_number) payload.vat_number = newSupplier.vat_number if (newSupplier.address_line1) payload.address_line1 = newSupplier.address_line1 if (newSupplier.bankgiro) payload.bankgiro = newSupplier.bankgiro if (newSupplier.plusgiro) payload.plusgiro = newSupplier.plusgiro if (newSupplier.default_expense_account) payload.default_expense_account = newSupplier.default_expense_account const res = await fetch('/api/suppliers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) const result = await res.json() if (!res.ok) { toast({ title: t('create_supplier_failed_title'), description: getErrorMessage(result, { context: 'supplier' }), variant: 'destructive' }) } else { const created = result.data as Supplier setSuppliers((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name))) setPendingSupplierSelect(created.id) setHasMatchedSupplier(true) setShowNewSupplier(false) setNewSupplier(EMPTY_NEW_SUPPLIER) toast({ title: t('supplier_created_title'), description: created.name }) } setIsCreatingSupplier(false) } function buildPayload(data: FormData) { const vatTreatment = inferVatTreatment(data.items, data.reverse_charge) // When paid privately, due_date is irrelevant: but the API still requires // a YYYY-MM-DD value. Default to invoice_date so the field passes validation. const dueDate = data.paid_with_private_funds && !data.due_date ? data.invoice_date : data.due_date return { supplier_id: data.supplier_id, ...(!inboxItemId && uploadedDocumentId ? { document_id: uploadedDocumentId } : {}), supplier_invoice_number: data.supplier_invoice_number, invoice_date: data.invoice_date, due_date: dueDate, delivery_date: data.delivery_date || undefined, currency: data.currency, exchange_rate: data.exchange_rate ? parseFloat(data.exchange_rate) : undefined, vat_treatment: vatTreatment, reverse_charge: data.reverse_charge, payment_reference: data.payment_reference || undefined, notes: data.notes || undefined, paid_with_private_funds: data.paid_with_private_funds, ore_rounding: oreRounding, // Invoice-level default dimensions (kostnadsställe/projekt): only sent // when the user actually picked something. ...(Object.keys(defaultDims).length > 0 ? { default_dimensions: defaultDims } : {}), items: data.items.map((item) => ({ description: item.description, amount: item.amount, account_number: item.account_number, // Reverse charge: the supplier charges no VAT, so the line rate is 0 and // the self-assessed rate travels on reverse_charge_rate (25% default). vat_rate: data.reverse_charge ? 0 : item.vat_rate, reverse_charge_rate: data.reverse_charge ? (item.reverse_charge_rate ?? 0.25) : undefined, // Periodisering: only sent when the row has a complete period AND the // flow supports it (kontantmetod/eget utlägg would be rejected by the // API: an AI prefill must never block those submits). ...(canUseAccrual && item.accrual_period_start && item.accrual_period_end ? { accrual_period_start: item.accrual_period_start, accrual_period_end: item.accrual_period_end, accrual_balance_account: item.accrual_balance_account || undefined, } : {}), // Per-item dimensions override: only when the bag carries values // (an open-but-empty panel means "inherit the invoice default"). ...(item.dimensions && Object.keys(item.dimensions).length > 0 ? { dimensions: item.dimensions } : {}), })), } } // Persist user edits back into the inbox item's extracted_data so the // inbox stays in sync with what was actually booked. Best-effort: a // failed PATCH never blocks the registration. async function patchInboxFieldsIfChanged(data: FormData) { if (!inboxItemId || !originalExtracted) return const supplierField: Record = {} const invoiceField: Record = {} if (originalExtracted.invoice?.invoiceNumber !== data.supplier_invoice_number) { invoiceField.invoiceNumber = data.supplier_invoice_number || null } if (originalExtracted.invoice?.invoiceDate !== data.invoice_date) { invoiceField.invoiceDate = data.invoice_date || null } if (originalExtracted.invoice?.dueDate !== data.due_date) { invoiceField.dueDate = data.due_date || null } if ((originalExtracted.invoice?.paymentReference || null) !== (data.payment_reference || null)) { invoiceField.paymentReference = data.payment_reference || null } if (originalExtracted.invoice?.currency !== data.currency) { invoiceField.currency = data.currency } if (Object.keys(supplierField).length === 0 && Object.keys(invoiceField).length === 0) return try { await fetch(`/api/extensions/ext/invoice-inbox/items/${inboxItemId}/fields`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...(Object.keys(supplierField).length ? { supplier: supplierField } : {}), ...(Object.keys(invoiceField).length ? { invoice: invoiceField } : {}), }), }) } catch { // Best-effort sync; don't block registration on this. } } // Single submit endpoint chooser: convert when we came from inbox, plain // POST otherwise. Both endpoints validate the same CreateSupplierInvoiceSchema // and return the same canonical error envelope ({ error: { code, message, // details } }): including the recoverable duplicate-number 409. async function postCreate(data: FormData): Promise<{ ok: boolean status: number result: CreateResult }> { const url = inboxItemId ? `/api/extensions/ext/invoice-inbox/items/${inboxItemId}/convert` : '/api/supplier-invoices' const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(buildPayload(data)), }) const result = await res.json() if ( res.ok && (result as CreateResult).warnings?.some((warning) => warning.code === 'DOCUMENT_LINK_FAILED') ) { toast({ title: t('document_link_warning_title'), description: t('document_link_warning_description'), variant: 'destructive', }) } return { ok: res.ok, status: res.status, result } } function onSubmit(data: FormData) { if (documentUploadInProgress) { toast({ title: t('document_upload_in_progress_title'), description: t('document_upload_in_progress_description'), variant: 'destructive', }) return } if (documentUploadFailed) { toast({ title: t('document_upload_failed_title'), description: t('document_upload_failed_description'), variant: 'destructive', }) return } // Hard block: under faktureringsmetoden (and for privately-paid kvitton) a // verifikation is posted at registration, and BFL 5 kap kräver att // verifikationsnumret ligger i en obruten serie inom ett räkenskapsår. No // räkenskapsår for the invoice date → no compliant voucher can exist, so we // refuse rather than register an unbooked invoice. Kontantmetoden books at // payment, so it is intentionally not blocked here (see showNoPeriodWarning). if (showNoPeriodWarning) { toast({ title: t('warning_title'), description: t('no_period_warning', { date: data.invoice_date }), variant: 'destructive', }) return } if (!data.supplier_id) { toast({ title: t('supplier_missing_title'), description: t('supplier_missing_description'), variant: 'destructive' }) return } if (!data.supplier_invoice_number) { toast({ title: t('invoice_number_missing_title'), description: t('invoice_number_missing_description'), variant: 'destructive' }) return } const rowWithoutAccount = data.items.findIndex((item) => !item.account_number) if (rowWithoutAccount !== -1) { toast({ title: t('account_missing_title'), description: t('account_missing_description', { row: rowWithoutAccount + 1 }), variant: 'destructive', }) return } // Only 25/12/6/0 % are legal Swedish VAT rates (ML 2023:200). The free-text // VatRateCell clamps to [0, 100] but accepts anything in between, so block // illegal rates here. Reverse-charge invoices skip this: their line vat_rate // is forced to 0 and RcRateSelect already restricts the self-assessed rate. if (!data.reverse_charge) { const rowWithIllegalRate = findIllegalVatRateRow(data.items) if (rowWithIllegalRate !== -1) { toast({ title: t('illegal_vat_rate_title'), description: t('illegal_vat_rate_description', { row: rowWithIllegalRate + 1, rate: rateToPctString(data.items[rowWithIllegalRate].vat_rate), }), variant: 'destructive', }) return } } // A row with an open periodisering panel must carry a complete period of // at least two calendar months before the invoice can be booked. const invalidAccrual = canUseAccrual && data.items.some((item) => { if (item.accrual_balance_account == null) return false if (!item.accrual_period_start || !item.accrual_period_end) return true if (item.accrual_period_end < item.accrual_period_start) return true return countCalendarMonths(item.accrual_period_start, item.accrual_period_end) < 2 }) if (invalidAccrual) { toast({ title: ta('incomplete_toast_title'), description: ta('incomplete_toast_description'), variant: 'destructive', }) return } if (submitModeRef.current === 'register_and_match') { // Open the bank-transaction picker; actual create happens on pick. // For AB the review dialog is shown after a transaction is picked. setPendingData(data) setShowBankPicker(true) return } // Privately-paid skips the AB review dialog: the toggle itself is the // explicit user intent, and the resulting verifikat is just expense + VAT // against the owner account (2893/2018). Same path for EF. if (isEF || data.paid_with_private_funds) { setPendingData(data) handleDirectSubmit(data) } else { setPendingData(data) setShowReview(true) } } // EF: create + auto-approve, no review dialog. Privately-paid invoices land // here too and skip auto-approve since they're already in status='paid'. async function handleDirectSubmit(data: FormData) { setIsSubmitting(true) await patchInboxFieldsIfChanged(data) const { ok, status, result } = await postCreate(data) if (!ok) { // EF/direct path also hits the duplicate-number 409 (e.g. converting an // inbox receipt whose number was already registered): offer recovery // instead of a dead-end toast. if (!tryHandleDuplicateConflict(status, result)) { handleCreateError(status, result) } setIsSubmitting(false) return } if (!result.data) { setIsSubmitting(false) return } // Clear dirty state so useUnsavedChanges doesn't fire the // beforeunload prompt while we navigate away on a successful submit. reset(data) if (data.paid_with_private_funds) { toast({ title: t('expense_registered_title'), description: t('arrival_number_label', { number: result.data.arrival_number }), }) finishCreate() setIsSubmitting(false) return } // Auto-approve for EF const approveRes = await fetch(`/api/supplier-invoices/${result.data.id}/approve`, { method: 'POST' }) if (!approveRes.ok) { toast({ title: t('warning_title'), description: t('auto_approve_failed_description'), variant: 'destructive', }) finishCreate(result.data.id) } else { toast({ title: t('invoice_registered_title'), description: t('arrival_number_label', { number: result.data.arrival_number }) }) finishCreate() } setIsSubmitting(false) } // AB: create after review dialog. If a bank transaction was picked first // (register-and-match flow), also match the new invoice to it. async function handleConfirm() { if (!pendingData) return setIsSubmitting(true) await patchInboxFieldsIfChanged(pendingData) const { ok, status, result } = await postCreate(pendingData) if (ok && result.data) { const invoiceId = result.data.id const arrivalNumber = result.data.arrival_number setShowReview(false) // Clear dirty state: see comment in handleDirectSubmit. reset(pendingData) if (pendingTransactionId) { const matchRes = await fetch(`/api/transactions/${pendingTransactionId}/match-supplier-invoice`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ supplier_invoice_id: invoiceId }), }) const matchResult = await matchRes.json() setPendingTransactionId(null) submitModeRef.current = 'register' if (matchRes.ok) { toast({ title: t('invoice_registered_and_matched_title'), description: t('invoice_registered_and_matched_description', { number: arrivalNumber }), }) } else { toast({ title: t('invoice_registered_match_failed_title'), description: getErrorMessage(matchResult, { context: 'supplier_invoice', statusCode: matchRes.status }), variant: 'destructive', }) } } else { toast({ title: t('invoice_registered_title'), description: t('arrival_number_label', { number: arrivalNumber }) }) } finishCreate(invoiceId) } else { // Treat duplicate-number as a recoverable conflict; everything else as a hard error. if (!tryHandleDuplicateConflict(status, result)) { handleCreateError(status, result) } } setIsSubmitting(false) } // Detect the recoverable duplicate-supplier-invoice-number conflict and open // the resolution dialog. Both the inbox `convert` route and the plain create // route return the same structured 409 envelope, so this works for every // submit path. Returns true when handled (caller should skip the error toast). function tryHandleDuplicateConflict(status: number, result: CreateResult): boolean { const err = result.error if ( status !== 409 || typeof err !== 'object' || err === null || err.code !== 'SI_CREATE_DUPLICATE_INVOICE_NUMBER' ) { return false } // Close the review dialog if it was the path that triggered the conflict; // a no-op for the EF/direct paths where it was never opened. setShowReview(false) setConflict({ message: err.message || t('duplicate_default_message'), existing: err.details?.existing ?? null, }) return true } // Shared error toast for non-conflict failures. function handleCreateError(status: number, result: CreateResult) { toast({ title: t('register_invoice_failed_title'), description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: status }), variant: 'destructive', }) } async function handleUncreditAndRetry() { if (!conflict?.existing) return const existingId = conflict.existing.id const existingNumber = conflict.existing.supplier_invoice_number setIsResolvingConflict(true) const uncreditRes = await fetch( `/api/supplier-invoices/${existingId}/uncredit`, { method: 'POST' }, ) const uncreditResult = await uncreditRes.json() if (!uncreditRes.ok) { toast({ title: t('uncredit_failed_title'), description: getErrorMessage(uncreditResult, { context: 'supplier_invoice', statusCode: uncreditRes.status }), variant: 'destructive', }) setIsResolvingConflict(false) return } setConflict(null) if (!pendingData) { setIsResolvingConflict(false) return } const { ok, status, result } = await postCreate(pendingData) setIsResolvingConflict(false) if (ok && result.data) { toast({ title: t('uncredit_and_register_success_title'), description: t('arrival_number_label', { number: result.data.arrival_number }), }) reset(pendingData) finishCreate(result.data.id) return } toast({ title: t('uncredit_but_register_failed_title'), description: t('uncredit_but_register_failed_description', { number: existingNumber, reason: getErrorMessage(result, { context: 'supplier_invoice', statusCode: status }), }), variant: 'destructive', }) } function handlePickNewNumber() { setConflict(null) setTimeout(() => invoiceNumberInputRef.current?.focus(), 0) } function handleDocumentFilesChange(nextFiles: UploadedFile[]) { const retainedKeys = new Set(nextFiles.map((file) => file.uploadKey)) const removedDocuments = documentFiles.filter( (file) => file.id && !retainedKeys.has(file.uploadKey), ) setDocumentFiles(nextFiles) for (const document of removedDocuments) { void fetch(`/api/documents/${document.id}`, { method: 'DELETE' }) } } async function discardUploadedDocument() { const ids = documentFiles .filter((file) => file.status === 'uploaded' && file.id) .map((file) => file.id as string) await Promise.allSettled( ids.map((documentId) => fetch(`/api/documents/${documentId}`, { method: 'DELETE' })), ) } function handleCancel() { void discardUploadedDocument().finally(() => { if (onCancel) onCancel() else router.push(inboxItemId ? '/e/general/invoice-inbox' : '/supplier-invoices') }) } // Match-on-create: register the invoice, then match the picked transaction. // EF goes straight through (auto-approve included). AB stores the picked // transaction and routes through the same review dialog as the plain // register flow: handleConfirm picks up the match step on confirmation. async function handlePickTransaction(transactionId: string) { if (!pendingData) return setShowBankPicker(false) if (!isEF) { setPendingTransactionId(transactionId) setShowReview(true) return } setIsSubmitting(true) await patchInboxFieldsIfChanged(pendingData) const { ok, status, result } = await postCreate(pendingData) if (!ok || !result.data) { if (!tryHandleDuplicateConflict(status, result)) { handleCreateError(status, result) } setIsSubmitting(false) return } const invoiceId = result.data.id const arrivalNumber = result.data.arrival_number // Auto-approve before matching, so the invoice is in the 'approved' state // that match-supplier-invoice expects (it accepts registered too, but // EF's expectation is fully-booked). await fetch(`/api/supplier-invoices/${invoiceId}/approve`, { method: 'POST' }) const matchRes = await fetch(`/api/transactions/${transactionId}/match-supplier-invoice`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ supplier_invoice_id: invoiceId }), }) const matchResult = await matchRes.json() setIsSubmitting(false) submitModeRef.current = 'register' if (matchRes.ok) { toast({ title: t('invoice_registered_and_matched_title'), description: t('invoice_registered_and_matched_description', { number: arrivalNumber }), }) } else { toast({ title: t('invoice_registered_match_failed_title'), description: getErrorMessage(matchResult, { context: 'supplier_invoice', statusCode: matchRes.status }), variant: 'destructive', }) } reset(pendingData) finishCreate(invoiceId) } return (
{/* In bare (dialog) mode the dialog renders the title. */} {!bare && (

{t('page_title')}

)} {isLoadingInbox && ( {t('loading_inbox')} )} {showAISupplierHint && (

{t('ai_suggested_supplier', { name: extractedData?.supplier?.name ?? '' })}

{extractedData?.supplier?.orgNumber ? t('ai_org_number', { orgNumber: extractedData.supplier.orgNumber }) : t('ai_no_org_number')} {t('ai_supplier_not_in_system')}

)}
{/* Section 1: Faktura */} {t('section_invoice')} {/* Eget utlägg-toggle. När den är på bokas verifikatet direkt mot skuld till ägare (2893/2018) istället för leverantörsskuld (2440), och fakturan får status "Betalad" direkt. */}
(
( )} />
{(() => { const { ref: rhfRef, ...rest } = register('supplier_invoice_number') return ( { rhfRef(el) invoiceNumberInputRef.current = el }} /> ) })()}
{!watchedPaidPrivately && ( <>
)}
{!inboxItemId && (

{t('document_help')}

)} {/* Invoice-level default dims (kostnadsställe/projekt): applied to every generated journal line; per-row bags in Kontering merge on top. Renders only when dimensions are enabled for the company. */} {dimensionsEnabled && (

{t('dimensions_default_hint')}

)} {showNoPeriodWarning && (

{t('no_period_warning', { date: watchedInvoiceDate })}

{t('no_period_help')}

)}
{/* Section 2: Kontering */} {t('section_accounting')} {/* Valuta & moms: kept inline with the line items because they drive how each row is interpreted. Hidden defaults (SEK + normal moms) collapse to nothing so most users don't see this. */}
( )} />
{watchedCurrency !== 'SEK' && (
{ userTouchedRateRef.current = true }, })} />
)}
( )} />
{/* Non-blocking account-range hint for reverse charge (#863 item 2) */} {rcAccountWarningRows.length > 0 && (

{t('rc_account_warning', { count: rcAccountWarningRows.length, rows: rcAccountWarningRows.map((i) => i + 1).join(', '), })}

)} {/* Non-blocking omvand-skattskyldighet hint for foreign suppliers invoiced at 0 % VAT (#1042). Mutually exclusive with the banner above: that one needs the switch on, this one needs it off. */} {foreignZeroVatRows.length > 0 && (

{t('foreign_zero_vat_warning', { count: foreignZeroVatRows.length, rows: foreignZeroVatRows.map((i) => i + 1).join(', '), })}

)} {/* Desktop table */}
{fields.map((field, index) => ( {canUseAccrual && isAccrualOpen(index) && ( )} {dimensionsEnabled && isDimOpen(index) && ( )} ))}
{t('col_account')} {t('col_description')} {t('col_amount_excl')} {watchedReverseCharge ? t('col_rc_vat_rate') : t('col_vat_rate')} {watchedReverseCharge ? t('col_rc_vat') : t('col_vat')}
( handleAccountChange(index, val)} /> )} /> ( )} /> ( field.onChange(e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)} /> )} /> {watchedReverseCharge ? ( ( )} /> ) : ( ( )} /> )} {formatAmount(itemTotals[index]?.vatAmount ?? 0)}
{dimensionsEnabled && ( )} {canUseAccrual && ( )} {fields.length > 1 && ( )}
{renderAccrualPanel(index, `accrual-desktop-${index}`)}
{renderDimensionsPanel(index)}
{/* Mobile cards */}
{fields.map((field, index) => (
{t('row_label', { index: index + 1 })}
{dimensionsEnabled && ( )} {canUseAccrual && ( )} {fields.length > 1 && ( )}
( handleAccountChange(index, val)} /> )} />
( )} />
( field.onChange(e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)} /> )} />
{watchedReverseCharge ? ( ( )} /> ) : ( ( )} /> )}
{watchedReverseCharge ? t('col_rc_vat') : t('col_vat')} {formatAmount(itemTotals[index]?.vatAmount ?? 0)}
{canUseAccrual && isAccrualOpen(index) && renderAccrualPanel(index, `accrual-mobile-${index}`)} {dimensionsEnabled && isDimOpen(index) && renderDimensionsPanel(index)}
))}
{/* AI totals comparison: only when extracted */} {extractedData?.totals && (extractedData.totals.subtotal != null || extractedData.totals.total != null) && (
{t('ai_totals_label')} {extractedData.totals.subtotal != null && ( {t('ai_net', { amount: formatAmount(extractedData.totals.subtotal) })} )} {extractedData.totals.vatAmount != null && ( {t('ai_vat', { amount: formatAmount(extractedData.totals.vatAmount) })} )} {extractedData.totals.total != null && ( {t('ai_total', { amount: formatAmount(extractedData.totals.total) })} )}
)} {/* Computed totals */}
{t('net_excl_vat')} {formatCurrency(subtotal, watchedCurrency)}
{watchedReverseCharge ? t('vat_reverse_charge') : t('vat_label_short')} {formatCurrency(totalVat, watchedCurrency)}
{displayRounding.applies && (
{t('ore_rounding_label')} {formatCurrency(displayRounding.roundingDelta, watchedCurrency)}
)}
{t('total_label')} {formatCurrency(displayRounding.displayed, watchedCurrency)}
{/* Öresavrundning: display-only rounding of the displayed total to whole kronor (SEK only). The registered amount and the booked verifikat keep the exact öre; this only changes what's shown. */} {(watchedCurrency || 'SEK') === 'SEK' && (

{t('ore_rounding_help')}

)}
{/* Section 3: Övrigt (collapsible) */} setAdvancedOpen(!advancedOpen)} >
{t('section_other')}
{advancedOpen && (