'use client'
import { Fragment, useState, useEffect, useRef, useCallback, type ReactNode } 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 } 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,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
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 { HOVER_REVEAL_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { exceedsHostedUploadLimit } from '@/lib/documents/upload-size'
import { uploadViaSignedUrl } from '@/lib/documents/direct-upload'
import { cn, formatAmount, formatCurrency, formatDate } 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 type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
import {
findReverseChargeAccountWarningRows,
findUnflaggedForeignZeroVatRows,
} from '@/lib/vat/supplier-invoice-line-checks'
import { SLP_RATE, isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { roundOre } from '@/lib/money'
import {
buildSupplierInvoicePayload,
type SupplierInvoiceFormData,
type SupplierInvoiceLineItem,
} from '@/lib/supplier-invoices/form-payload'
import { plantedRowTouched, snapshotPlantedRow } from '@/lib/supplier-invoices/planted-rows'
import { VatRateCell, RcRateSelect, AmountCell } from '@/components/supplier-invoices/supplier-invoice-cells'
import { useSupplierInvoiceData } from '@/components/supplier-invoices/use-supplier-invoice-data'
import { useInboxPrefill, type InboxItemData } from '@/components/supplier-invoices/use-inbox-prefill'
import { useSupplierInvoiceSubmit } from '@/components/supplier-invoices/use-supplier-invoice-submit'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, AlertTriangle, MessageCircle, Link2, CalendarClock, Tags, FileText } from 'lucide-react'
import type { Supplier, InvoiceExtractionResult } from '@/types'
// The form's line/field shapes live in lib/supplier-invoices/form-payload.ts
// next to the payload builder they feed, pinned there by parity tests.
type FormData = SupplierInvoiceFormData
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 *
}
// Accepts sv-SE formatted numbers ("12 345,67" with regular, no-break or
// narrow no-break group separators) as well as plain "12345.67".
function parseFlexibleNumber(s: string): number | null {
const cleaned = s.replace(/[\s ]/g, '').replace(',', '.')
if (!cleaned) return null
const n = parseFloat(cleaned)
return Number.isFinite(n) ? n : null
}
const EMPTY_NEW_SUPPLIER: NewSupplierForm = {
name: '',
supplier_type: 'swedish_business',
org_number: '',
vat_number: '',
address_line1: '',
bankgiro: '',
plusgiro: '',
default_expense_account: '',
}
// Dense in-table inputs: leaf tier inside the Kontering table surface.
const CELL_INPUT_CLASS =
'h-8 rounded-sm border-transparent bg-transparent px-2 text-[13px] hover:bg-secondary/60 focus-visible:bg-card'
// Row controls: 24x24 hit area (pre-approved dense-row exception to the 40px
// icon-button floor), revealed on hover/focus-within, always on coarse pointers.
const ROW_ICON_BTN_CLASS =
'inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-secondary/60 hover:text-foreground'
const UNDERLAG_ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
const UNDERLAG_ACCEPT_ATTR = '.pdf,.jpg,.jpeg,.png,.webp'
const UNDERLAG_MAX_SIZE = 10 * 1024 * 1024
// A deferred web upload returns an empty extraction skeleton (confidence 0)
// while processing; only apply a prefill that actually carries content.
function hasExtractionContent(e: InvoiceExtractionResult | null | undefined): boolean {
if (!e) return false
return Boolean(
e.supplier?.name ||
e.invoice?.invoiceNumber ||
e.invoice?.invoiceDate ||
(e.lineItems && e.lineItems.length > 0) ||
e.totals?.total != null,
)
}
function SectionLabel({ children }: { children: ReactNode }) {
return (
{children}
)
}
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')
// Loads the BAS chart chunk after mount and re-renders once names and
// descriptions for non-hardcoded accounts are available.
useBasReference()
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,
refreshSuppliers,
suppliersLoaded,
accounts,
entityType,
accountingMethod,
oreRounding,
setOreRounding,
dimensionsEnabled,
vatRegistered,
periods,
periodsLoaded,
} = useSupplierInvoiceData()
const [defaultDims, setDefaultDims] = useState>({})
const [showNewSupplier, setShowNewSupplier] = useState(false)
const [isCreatingSupplier, setIsCreatingSupplier] = useState(false)
const [pendingSupplierSelect, setPendingSupplierSelect] = useState(null)
const [newSupplier, setNewSupplier] = useState(EMPTY_NEW_SUPPLIER)
const [documentFiles, setDocumentFiles] = useState([])
const documentFilesRef = useRef([])
const createFinishedRef = useRef(false)
const invoiceNumberInputRef = useRef(null)
// Dokument-först state: a standalone upload routed through the invoice-inbox
// pipeline gets an inbox item id (extraction + convert endpoint); a fallback
// upload through /api/documents stays a plain attached document.
const [uploadedInboxItemId, setUploadedInboxItemId] = useState(null)
const uploadedInboxItemIdRef = useRef(null)
const [extractionPhase, setExtractionPhase] = useState<'idle' | 'processing' | 'done'>('idle')
const extractionPollTokenRef = useRef(0)
const underlagInputRef = useRef(null)
const [isDraggingUnderlag, setIsDraggingUnderlag] = useState(false)
// Deferred tolkning that landed while the user was already typing: buffered
// instead of applied, surfaced as a quiet click-to-apply line (see
// applyExtractionIfPristine below).
const [pendingExtraction, setPendingExtraction] = useState(null)
// Fields the tolkning just filled: they get the brief settle tint (the CSS
// animation is gated on prefers-reduced-motion in globals.css).
const [settledFields, setSettledFields] = useState>(() => new Set())
// Terms-based due date (change 4): auto-filled from the supplier's
// default_payment_terms until the user (or the AI) sets it explicitly.
const dueDateManualRef = useRef(false)
const lastAutoDueRef = useRef(null)
const [dueDateCaption, setDueDateCaption] = useState<
{ type: 'terms'; days: number } | { type: 'on_invoice' } | null
>(null)
// Pre-submit duplicate advisory (change 3): debounced lookup against
// GET /api/supplier-invoices/exists; the create route's 409 stays the backstop.
const [duplicateWarning, setDuplicateWarning] = useState<{
number: string
existingId: string | null
} | null>(null)
const duplicateSeqRef = useRef(0)
// Total cross-check (change 2): client-only compare, never in the payload.
const [fakturaTotalStr, setFakturaTotalStr] = useState('')
// Shell state
const [forvalOpen, setForvalOpen] = useState(false)
const [supplierMenuOpen, setSupplierMenuOpen] = useState(false)
const supplierTriggerRef = useRef(null)
const entryInputRef = useRef(null)
const [entryResetKey, setEntryResetKey] = useState(0)
const amountInputRefs = useRef>({})
const [pendingAmountFocus, setPendingAmountFocus] = useState(null)
const accountInputRefs = useRef>({})
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,
// The table starts empty: the ghost entry row (never part of form
// state) is the only way rows are born, so no silent prefilled expense
// account can ever reach a verifikat unnoticed.
items: [],
},
})
useUnsavedChanges(isDirty)
// Live dirty flag for long-lived closures (the 90 s extraction poll):
// the destructured `isDirty` in that closure is frozen at poll start.
const isDirtyRef = useRef(false)
useEffect(() => {
isDirtyRef.current = isDirty
}, [isDirty])
useEffect(() => {
documentFilesRef.current = documentFiles
}, [documentFiles])
useEffect(() => {
uploadedInboxItemIdRef.current = uploadedInboxItemId
}, [uploadedInboxItemId])
// Stop any in-flight extraction poll on unmount.
useEffect(() => () => {
extractionPollTokenRef.current += 1
}, [])
useEffect(() => () => {
if (inboxItemId || createFinishedRef.current) return
// A standalone upload that went through the inbox pipeline created an
// inbox item alongside the document: clean up both (same orphan-cleanup
// contract as the plain path; the item DELETE 409s if it was converted).
const itemId = uploadedInboxItemIdRef.current
if (itemId) {
void fetch(`/api/extensions/ext/invoice-inbox/items/${itemId}`, {
method: 'DELETE',
keepalive: true,
})
}
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 watchedDeliveryDate = watch('delivery_date')
const watchedNotes = watch('notes')
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
// Settle-tint bookkeeping: applyInboxItem reports every field it fills.
const markSettled = useCallback((field: string) => {
setSettledFields((prev) => {
const next = new Set(prev)
next.add(field)
return next
})
}, [])
const handleFieldPrefilled = useCallback(
(field: string) => {
if (field === 'due_date') {
// An AI-extracted due date is an explicit value: the terms-based
// auto-update must never overwrite it.
dueDateManualRef.current = true
setDueDateCaption(null)
}
markSettled(field)
},
[markSettled],
)
useEffect(() => {
if (settledFields.size === 0) return
const timer = setTimeout(() => setSettledFields(new Set()), 1200)
return () => clearTimeout(timer)
}, [settledFields])
const {
extractedData,
originalExtracted,
hasMatchedSupplier,
setHasMatchedSupplier,
isLoadingInbox,
applyCount: prefillApplyCount,
applyInboxItem,
} = useInboxPrefill({
inboxItemId,
suppliersLoaded,
suppliers,
setValue,
replace,
reset,
getValues,
toast,
t,
onFieldPrefilled: handleFieldPrefilled,
})
// 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,
)
// Auto-fill defaults when supplier is selected: but never overwrite a value
// the AI already filled in for us.
const [templateAccountNote, setTemplateAccountNote] = useState<{ account: string; counterparty: string } | null>(null)
// Rows planted by a supplier default or the counterparty-history prefill, so
// a supplier SWITCH can un-plant them: without this, supplier A's account
// survives into supplier B's invoice and silently blocks B's own
// default_expense_account (the fill branches only touch empty rows). Each
// row carries a plant-time snapshot (dirtyFields is unreliable for appended
// array rows: they are born all-dirty) plus whether the plant created the
// row, so un-planting can tell an untouched planted row from one the user
// has edited.
const plantedRef = useRef<{
account: string
rows: { index: number; appended: boolean; snapshot: SupplierInvoiceLineItem }[]
} | null>(null)
// Automatic fill is requested, not applied inline: handleAccountChange
// needs the loaded BAS chart to apply the konto's default moms, and the
// requests originate in closures (the supplier effect and its async
// template fetch) that may hold a stale empty `accounts`. The applying
// effect below re-runs on both the request tick and the chart load with
// fresh closures, so whichever arrives last triggers the fill. Filling
// early would leave a VAT-free konto on the 25% row default, the exact
// mis-booking the fill exists to prevent.
const pendingAccountFillRef = useRef<{ account: string; counterparty?: string } | null>(null)
const [accountFillTick, setAccountFillTick] = useState(0)
function requestAccountFill(account: string, counterparty?: string) {
pendingAccountFillRef.current = { account, counterparty }
setAccountFillTick((t) => t + 1)
}
useEffect(() => {
if (accounts.length === 0 || !pendingAccountFillRef.current) return
const { account, counterparty } = pendingAccountFillRef.current
pendingAccountFillRef.current = null
const items = getValues('items')
const appliedRows: { index: number; appended: boolean }[] = []
if (items.length === 0) {
// Dokument-först model: the table starts with no rows, so a supplier
// default (or history) account plants the first row instead of filling
// an empty one. Same side effects as a manual pick (konto default moms,
// description from the account name).
appliedRows.push({ index: appendRowForAccount(account), appended: true })
} else {
items.forEach((row, i) => {
if (!row.account_number) {
// Same path as a manual pick: konto default moms rides along.
handleAccountChange(i, account)
appliedRows.push({ index: i, appended: false })
}
})
}
// Every plant registers in plantedRef: default_expense_account plants must
// follow the same un-plant rules on a supplier switch as history plants
// (only the history plant gets the counterparty note).
if (appliedRows.length > 0) {
plantedRef.current = {
account,
rows: appliedRows.map(({ index, appended }) => ({
index,
appended,
snapshot: snapshotPlantedRow(getValues(`items.${index}`)),
})),
}
if (counterparty) setTemplateAccountNote({ account, counterparty })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accounts, accountFillTick])
useEffect(() => {
if (!watchedSupplierId) return
const supplier = suppliers.find((s) => s.id === watchedSupplierId)
if (!supplier) return
setTemplateAccountNote(null)
pendingAccountFillRef.current = null
if (plantedRef.current) {
const { account, rows } = plantedRef.current
const planted = getValues('items')
// Rows the plant itself created and the user never touched since
// (snapshot compare: belopp, beskrivning, moms, periodisering,
// dimensioner, SLP) are removed outright: in the empty-start table a
// planted row IS the row, not just an account on one. Any user edit,
// not just an amount, keeps the row's data and only the stale account
// is cleared (handleAccountChange reapplies konto defaults on the next
// fill or manual pick). Rows that existed before the fill are never
// removed, only un-planted. Descending order keeps the remaining
// indices valid while removing.
;[...rows].sort((a, b) => b.index - a.index).forEach(({ index, appended, snapshot }) => {
const row = planted[index]
if (row?.account_number !== account) return
if (appended && !plantedRowTouched(row, snapshot)) {
remove(index)
} else {
setValue(`items.${index}.account_number`, '')
}
})
plantedRef.current = null
}
applySupplierTerms(supplier)
if (supplier.default_expense_account) {
// Fill every row the user hasn't assigned yet (or plant the first row
// when the table is empty): an empty account is the only signal needed.
// Routed through the fill request so it waits for the BAS chart and the
// konto's default moms comes along exactly like a manual pick.
requestAccountFill(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)
}
// No supplier default: fall back to the company's own booking history for
// this counterparty (the same tiered matcher the booking flows use). Fills
// empty rows only, never a generic seed, and only from expense-shaped
// templates (P&L cost on debit, settlement on credit; the 4-8 gate keeps
// private/balance-sheet templates like 2013 or 1630 out). Best-effort: on
// any miss the rows simply stay blank, exactly as before.
if (!supplier.default_expense_account && supplier.name?.trim()) {
let cancelled = false
;(async () => {
try {
const res = await fetch(
`/api/settings/counterparty-templates?counterparty=${encodeURIComponent(supplier.name.trim())}`
)
if (!res.ok) return
const json = await res.json()
if (cancelled) return
const match = json?.data
const debit: string | undefined = match?.template?.debit_account
const credit: string | undefined = match?.template?.credit_account
if (!match || (match.confidence ?? 0) < 0.5) return
if (!debit || !/^[4-8]/.test(debit) || !credit || !credit.startsWith('19')) return
requestAccountFill(debit, match.template.counterparty_name)
} catch {
// Prefill is best-effort; the rows stay blank.
}
})()
return () => { cancelled = true }
}
return undefined
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [watchedSupplierId, suppliers])
function computeDueDate(invoiceDate: string, days: number): string {
const due = new Date(invoiceDate)
due.setDate(due.getDate() + days)
return due.toISOString().split('T')[0]
}
// Terms-based förfallodatum (change 4). Auto-set from the supplier's
// default_payment_terms with an honest caption; stops updating the moment
// the user (or the AI extraction) supplies an explicit date.
function applySupplierTerms(supplier: Supplier) {
if (dueDateManualRef.current) return
const invoiceDate = watch('invoice_date')
const currentDue = watch('due_date')
// Never overwrite a due date we did not auto-set ourselves (the AI
// prefill flips the manual flag, but keep the value guard as defense
// in depth).
if (currentDue && currentDue !== lastAutoDueRef.current) return
const days = supplier.default_payment_terms
if (days && days > 0) {
if (invoiceDate) {
const due = computeDueDate(invoiceDate, days)
setValue('due_date', due)
lastAutoDueRef.current = due
}
setDueDateCaption({ type: 'terms', days })
} else {
// default_payment_terms is non-null in the DB (default 30), so 30 IS
// terms; only an explicit 0 means the supplier has none and the due
// date stands on the invoice.
if (currentDue) setValue('due_date', '')
lastAutoDueRef.current = null
setDueDateCaption({ type: 'on_invoice' })
}
}
// Re-derive an auto-set due date when the invoice date changes: manual or
// AI-set dates are left alone via the same guards as applySupplierTerms.
useEffect(() => {
if (!watchedSupplierId || dueDateManualRef.current) return
const supplier = suppliers.find((s) => s.id === watchedSupplierId)
if (!supplier) return
const currentDue = getValues('due_date')
if (currentDue && currentDue !== lastAutoDueRef.current) return
const days = supplier.default_payment_terms
if (days && days > 0 && watchedInvoiceDate) {
const due = computeDueDate(watchedInvoiceDate, days)
setValue('due_date', due)
lastAutoDueRef.current = due
setDueDateCaption({ type: 'terms', days })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [watchedInvoiceDate])
// Pre-submit duplicate advisory (change 3): debounced, sequence-guarded and
// purely advisory: the create route's structured 409 stays the enforcement
// point, and the exists endpoint mirrors the unique index's
// credited/reversed exclusion so re-issues never warn.
useEffect(() => {
const supplierId = watchedSupplierId
const number = (watchedInvoiceNumber || '').trim()
if (!supplierId || !number) {
// Advance the seq so an in-flight exists response cannot resurrect the
// warning under a field the user just cleared.
duplicateSeqRef.current += 1
setDuplicateWarning(null)
return
}
const seq = ++duplicateSeqRef.current
const timer = setTimeout(async () => {
try {
const res = await fetch(
`/api/supplier-invoices/exists?supplier_id=${encodeURIComponent(supplierId)}&number=${encodeURIComponent(number)}`,
)
if (!res.ok) return
const json = await res.json()
if (duplicateSeqRef.current !== seq) return
setDuplicateWarning(
json?.data?.exists
? { number, existingId: json.data.existing?.id ?? null }
: null,
)
} catch {
// Advisory only; the 409 backstop still catches real duplicates.
}
}, 400)
return () => clearTimeout(timer)
}, [watchedSupplierId, watchedInvoiceNumber])
// 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])
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 (vatRegistered && !watchedReverseCharge && defaultRate != null && Number.isFinite(defaultRate)) {
setValue(`items.${index}.vat_rate`, defaultRate, { shouldDirty: true })
}
// Särskild löneskatt only applies to 741x pension premiums: leaving the
// range clears the flag so a stale opt-in can never reach the API.
if (watch(`items.${index}.apply_slp`) && !isSlpPensionAccount(accountNumber)) {
setValue(`items.${index}.apply_slp`, undefined, { shouldDirty: true })
}
}
// Append a new kontering row for a committed account: the same side effects
// as a manual pick on an existing row (description from the account name,
// konto default moms), used by the ghost entry row and the supplier-default
// plant. Returns the new row's index.
function appendRowForAccount(accountNumber: string): number {
const acct = accounts.find((a) => a.account_number === accountNumber)
const defaultRate = acct?.default_vat_rate == null ? null : Number(acct.default_vat_rate)
const vatRate = !vatRegistered
? 0
: !watchedReverseCharge && defaultRate != null && Number.isFinite(defaultRate)
? defaultRate
: 0.25
const description =
accountNumber.length === 4 ? getAccountDescription(accountNumber)?.name ?? '' : ''
const index = getValues('items').length
append({
description,
amount: 0,
account_number: accountNumber,
vat_rate: vatRate,
reverse_charge_rate: 0.25,
})
return index
}
// Ghost entry row commit: append the row, clear the entry input (remount)
// and move focus to the new row's amount cell.
function commitEntryAccount(accountNumber: string) {
if (!accountNumber) return
const index = appendRowForAccount(accountNumber)
setEntryResetKey((k) => k + 1)
// Focus hand-off via effect, not requestAnimationFrame: the entry input
// remounts on commit while focused, and the dialog's focus scope
// re-parks focus before any frame callback can win. The effect below
// runs after the new row's input is mounted, deterministically.
setPendingAmountFocus(index)
}
useEffect(() => {
if (pendingAmountFocus === null) return
const el = amountInputRefs.current[pendingAmountFocus]
if (!el) return
el.focus()
el.select()
setPendingAmountFocus(null)
}, [pendingAmountFocus, fields.length])
// 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])
// Force every line to 0 % moms for icke momsregistrerade companies: the
// default line, AI prefills and konto defaults all assume 25 % otherwise.
// Re-runs after EVERY applied extraction (prefillApplyCount bumps per
// apply), not just the first: a remove + re-upload applies a fresh
// extraction whose 25 % rates would otherwise reach the convert endpoint
// silently, with the moms columns hidden.
//
// The amount is grossed up in the same pass: an amount paired with a
// non-zero rate is a NET amount (AI line totals are exkl moms, and the
// visible column said "Belopp (exkl.)" while the rate stood). For a company
// with no deduction right the moms is part of the cost, so net at 25 %
// becomes gross at 0 %; zeroing the rate alone would understate both the
// expense and 2440 by exactly the moms.
useEffect(() => {
if (vatRegistered) return
const items = getValues('items') ?? []
items.forEach((item, index) => {
if (item.vat_rate !== 0) {
if (item.amount) {
setValue(`items.${index}.amount`, roundOre(item.amount * (1 + item.vat_rate)))
}
setValue(`items.${index}.vat_rate`, 0)
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vatRegistered, prefillApplyCount])
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 },
)
// Periodisering + särskild löneskatt on the same row is rejected by the
// API (SI_CREATE_SLP_ACCRUAL): opening the accrual panel wins and the
// SLP opt-in is cleared (its confirmation row disappears with it).
if (watch(`items.${index}.apply_slp`)) {
setValue(`items.${index}.apply_slp`, undefined, { shouldDirty: true })
}
}
}
// --- Särskild löneskatt på pensionskostnader (SLP, 24,26 %) ---
// A 741x pension-premium row gets an advisory hint (add the 7533/2514
// pair) or, once opted in, a quiet confirmation line. The pair nets to
// zero, so the totals box below is untouched: the invoice total IS the
// payable. Hidden while the row's periodisering panel is open (the API
// rejects the combination).
function slpRowVisible(index: number): boolean {
const item = watchedItems?.[index]
if (!item) return false
if (!isSlpPensionAccount(item.account_number || '')) return false
if (canUseAccrual && isAccrualOpen(index)) return false
return true
}
function setSlp(index: number, value: boolean) {
setValue(`items.${index}.apply_slp`, value ? true : undefined, { shouldDirty: true })
}
function renderSlpPanel(index: number) {
const item = watchedItems?.[index]
if (!item) return null
const slpAmount = roundOre((item.amount || 0) * SLP_RATE)
if (item.apply_slp) {
return (