feat(supplier-invoices): "Vem betalade?" control replaces the paid privately switch and books an open utlägg (#2362)

The supplier-invoice form asks who paid with the same control as the
Underlag pane (Företaget / Jag, privat / En anställd / Ingen ännu) instead
of its own switch under Förval. A person paying is an utlägg: the route
hands the invoice to registerExpenseClaim with the invoice's kontering as
the claim's lines, so the verifikat and the expense_claims row come from
the same writer as the Underlag pane, the person shows up under "Betala ut
utlägg" on Hem and the bank matcher closes the debt. Employees book on
2820 with employee_id; the owner's blank name falls back to the shared
label so Hem groups one person.

Also routes a person-paid inbox document through the core route with
inbox_item_id: the extension's convert endpoint never read
paid_with_private_funds, so the old switch was silently dropped whenever
a receipt was attached. The second entry generator, the Förval switch,
the outline "Registrera & markera som betald" button and the duplicated
owner/employee picker are removed; PayerChoiceSelect and the claimant
fields move to components/expenses so core and the extension share them.

Closes #2332


Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 21:04:17 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 6776cb4fc6
commit ebbe50c0f3
20 changed files with 1318 additions and 575 deletions
@@ -0,0 +1,117 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
import { OWNER_FALLBACK_NAME, type ExpensePayer } from '@/lib/expenses/payer'
interface EmployeeOption {
id: string
first_name: string
last_name: string
}
/**
* Who the utlägg belongs to, once "Vem betalade?" is a person: the owner's
* name (optional, the shared fallback label otherwise) or one of the
* company's employees. Shared by the Underlag dialog and the supplier-invoice
* form so both post the same claimant to the same claims writer. Employees
* are fetched on first need; the host only sees the chosen id and name.
*/
export function ExpenseClaimantFields({
payer,
ownerName,
onOwnerNameChange,
employeeId,
onEmployeeChange,
disabled,
idPrefix = 'claimant',
className,
labelClassName,
inputClassName,
}: {
payer: ExpensePayer
ownerName: string
onOwnerNameChange: (name: string) => void
employeeId: string
/** The picked employee's id and display name ('' when cleared). */
onEmployeeChange: (id: string, name: string) => void
disabled?: boolean
idPrefix?: string
className?: string
labelClassName?: string
inputClassName?: string
}) {
const t = useTranslations('inbox_workspace')
const [employees, setEmployees] = useState<EmployeeOption[]>([])
const [employeesLoaded, setEmployeesLoaded] = useState(false)
useEffect(() => {
if (payer !== 'employee' || employeesLoaded) return
let cancelled = false
fetch('/api/salary/employees')
.then((res) => (res.ok ? res.json() : null))
.then((json) => {
if (!cancelled) setEmployees((json?.data ?? []) as EmployeeOption[])
})
.catch(() => {
if (!cancelled) setEmployees([])
})
.finally(() => {
if (!cancelled) setEmployeesLoaded(true)
})
return () => {
cancelled = true
}
}, [payer, employeesLoaded])
if (payer === 'owner') {
return (
<div className={cn('space-y-1.5', className)}>
<Label htmlFor={`${idPrefix}-owner`} className={labelClassName}>
{t('expense_owner_name')}
</Label>
<Input
id={`${idPrefix}-owner`}
value={ownerName}
onChange={(e) => onOwnerNameChange(e.target.value)}
placeholder={OWNER_FALLBACK_NAME}
disabled={disabled}
className={inputClassName}
/>
</div>
)
}
return (
<div className={cn('space-y-1.5', className)}>
<Label htmlFor={`${idPrefix}-employee`} className={labelClassName}>
{t('expense_employee')}
</Label>
<Select
value={employeeId}
onValueChange={(id) => {
const picked = employees.find((e) => e.id === id)
onEmployeeChange(id, picked ? `${picked.first_name} ${picked.last_name}`.trim() : '')
}}
disabled={disabled}
>
<SelectTrigger id={`${idPrefix}-employee`} className={inputClassName}>
<SelectValue
placeholder={employeesLoaded && employees.length === 0 ? t('expense_no_employees') : t('expense_pick_employee')}
/>
</SelectTrigger>
<SelectContent>
{employees.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.first_name} {e.last_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
'use client'
import { useTranslations } from 'next-intl'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { PAYER_ORDER, type PayerChoice } from '@/lib/expenses/payer'
import type { AccountingMethod } from '@/types'
export type { ExpensePayer, PayerChoice } from '@/lib/expenses/payer'
/**
* The "Vem betalade?" control, shared by the Underlag pane and the
* supplier-invoice form: a compact select so a rail keeps its primary button
* above the fold, with the chosen answer's one-line consequence under it.
* Each option in the list carries the same help so the choice is made with
* the consequence visible, not after.
*
* 'company' -> match the bank line; 'unpaid' -> supplier invoice (2440);
* 'owner' / 'employee' -> utlägg against the person's liability account.
*/
export function PayerChoiceSelect({
value,
onChange,
accountingMethod,
id,
labelClassName,
disabled,
}: {
value: PayerChoice
onChange: (next: PayerChoice) => void
accountingMethod: AccountingMethod
/** Trigger id, so a host can point a label or focus router at it. */
id?: string
/** Overrides the rail's label style when the control sits in a form grid. */
labelClassName?: string
disabled?: boolean
}) {
const t = useTranslations('inbox_workspace')
// An enskild firma owner makes an egen insättning, not a loan to the
// company: no debt, nothing to pay out, so the help line says so.
const isEf = useCompanyOptional()?.company?.entity_type === 'enskild_firma'
// Företaget carries no help line: the button under it ("Matcha mot
// transaktion") already says what happens. The other answers name the
// liability the company takes on, which is the consequence worth reading.
const helpKey = (choice: PayerChoice): string | null =>
choice === 'company'
? null
: choice === 'owner' && isEf
? 'payer_help_owner_ef'
: choice === 'unpaid' && accountingMethod === 'cash'
? 'payer_help_unpaid_cash'
: `payer_help_${choice}`
const selectedHelp = helpKey(value)
return (
<div className="space-y-1.5">
<p className={labelClassName ?? 'text-[13px] font-medium'}>{t('payer_question')}</p>
<Select value={value} onValueChange={(next) => onChange(next as PayerChoice)} disabled={disabled}>
<SelectTrigger id={id} aria-label={t('payer_question')} className="h-9 text-[13px]">
{/* Explicit children: the items render label + help, and the
trigger must show the label alone. */}
<SelectValue>{t(`payer_${value}`)}</SelectValue>
</SelectTrigger>
{/* Match the trigger width so the two-line options wrap inside the rail
instead of spilling over the document viewer. */}
<SelectContent align="start" className="w-[var(--radix-select-trigger-width)]">
{PAYER_ORDER.map((choice) => (
<SelectItem key={choice} value={choice} className="py-2">
<span className="block text-[13px]">{t(`payer_${choice}`)}</span>
{helpKey(choice) && (
<span className="block text-xs text-muted-foreground">{t(helpKey(choice)!)}</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedHelp && <p className="text-xs text-muted-foreground">{t(selectedHelp)}</p>}
</div>
)
}
@@ -23,7 +23,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import {
Inbox,
@@ -76,6 +75,7 @@ import {
} from '@/lib/documents/inbox-kind'
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
import RegisterExpenseDialog, { type ExpensePayer } from '@/components/extensions/general/RegisterExpenseDialog'
import { PayerChoiceSelect, type PayerChoice } from '@/components/expenses/PayerChoiceSelect'
import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog'
// InboxCustomDomainDialog (egen domän) is built but gated off: see
@@ -3092,75 +3092,6 @@ function ProposedBooking({
)
}
// ── Vem betalade? ────────────────────────────────────────────
/**
* How an unmatched underlag gets booked, phrased as who paid for it.
* 'company' → match the bank line; 'unpaid' → supplier invoice (2440);
* 'owner' / 'employee' → utlägg against the person's liability account.
*/
export type PayerChoice = 'company' | 'unpaid' | ExpensePayer
const PAYER_ORDER: PayerChoice[] = ['company', 'owner', 'employee', 'unpaid']
/**
* The "Vem betalade?" control: a compact select so the rail keeps its
* primary button above the fold, with the chosen answer's one-line
* consequence under it. Each option in the list carries the same help so
* the choice is made with the consequence visible, not after.
*/
export function PayerChoiceSelect({
value,
onChange,
accountingMethod,
}: {
value: PayerChoice
onChange: (next: PayerChoice) => void
accountingMethod: AccountingMethod
}) {
const t = useTranslations('inbox_workspace')
// An enskild firma owner makes an egen insättning, not a loan to the
// company: no debt, nothing to pay out, so the help line says so.
const isEf = useCompanyOptional()?.company?.entity_type === 'enskild_firma'
// Företaget carries no help line: the button under it ("Matcha mot
// transaktion") already says what happens. The other answers name the
// liability the company takes on, which is the consequence worth reading.
const helpKey = (choice: PayerChoice): string | null =>
choice === 'company'
? null
: choice === 'owner' && isEf
? 'payer_help_owner_ef'
: choice === 'unpaid' && accountingMethod === 'cash'
? 'payer_help_unpaid_cash'
: `payer_help_${choice}`
const selectedHelp = helpKey(value)
return (
<div className="space-y-1.5">
<p className="text-[13px] font-medium">{t('payer_question')}</p>
<Select value={value} onValueChange={(next) => onChange(next as PayerChoice)}>
<SelectTrigger aria-label={t('payer_question')} className="h-9 text-[13px]">
{/* Explicit children: the items render label + help, and the
trigger must show the label alone. */}
<SelectValue>{t(`payer_${value}`)}</SelectValue>
</SelectTrigger>
{/* Match the trigger width so the two-line options wrap inside the rail
instead of spilling over the document viewer. */}
<SelectContent align="start" className="w-[var(--radix-select-trigger-width)]">
{PAYER_ORDER.map((choice) => (
<SelectItem key={choice} value={choice} className="py-2">
<span className="block text-[13px]">{t(`payer_${choice}`)}</span>
{helpKey(choice) && (
<span className="block text-xs text-muted-foreground">{t(helpKey(choice)!)}</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedHelp && <p className="text-xs text-muted-foreground">{t(selectedHelp)}</p>}
</div>
)
}
// ── Fields rail ──────────────────────────────────────────────
function FieldsRail({
@@ -14,29 +14,22 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { ExpenseClaimantFields } from '@/components/expenses/ExpenseClaimantFields'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { useAccounts } from '@/lib/reference-data/hooks'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { ACCOUNT_NUMBER_RE, ISO_DATE_RE } from '@/lib/invariants'
import { OWNER_FALLBACK_NAME, resolveExpenseLiabilityAccount, type ExpensePayer } from '@/lib/expenses/payer'
import type { InvoiceExtractionResult } from '@/types'
/**
* Who paid for the underlag out of their own pocket. The owner's liability
* account follows the entity type (2893 skuld till ägare in an AB, 2018 egen
* insättning in an enskild firma); an employee is always 2820.
*/
export type ExpensePayer = 'owner' | 'employee'
// Who paid for the underlag out of their own pocket. The account rule (2893
// AB owner / 2018 EF owner / 2820 employee) lives in lib/expenses/payer.ts,
// shared with the supplier-invoice form.
export type { ExpensePayer }
interface InboxItemLike {
id: string
@@ -44,12 +37,6 @@ interface InboxItemLike {
extracted_data: InvoiceExtractionResult | null
}
interface EmployeeOption {
id: string
first_name: string
last_name: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
@@ -59,8 +46,6 @@ interface Props {
onSuccess: () => void | Promise<void>
}
const OWNER_FALLBACK_NAME = 'Ägare'
function todayIso(): string {
// Local calendar date: toISOString() is UTC and would date a receipt booked
// after midnight CEST to the previous day (wrong period, wrong FX rate).
@@ -88,8 +73,7 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
const { toast } = useToast()
const { accounts } = useAccounts()
const entityType = useCompanyOptional()?.company?.entity_type ?? null
const ownerLiability = entityType === 'enskild_firma' ? '2018' : '2893'
const liabilityAccount = payer === 'owner' ? ownerLiability : '2820'
const liabilityAccount = resolveExpenseLiabilityAccount(entityType, payer)
const data = item.extracted_data
const [description, setDescription] = useState('')
@@ -99,8 +83,7 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
const [expenseAccount, setExpenseAccount] = useState('')
const [ownerName, setOwnerName] = useState('')
const [employeeId, setEmployeeId] = useState('')
const [employees, setEmployees] = useState<EmployeeOption[]>([])
const [employeesLoaded, setEmployeesLoaded] = useState(false)
const [employeeName, setEmployeeName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const currency = (data?.invoice?.currency ?? 'SEK').toUpperCase()
// A foreign receipt carries VAT the company cannot deduct on 2641: the whole
@@ -120,29 +103,15 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
setVatInput(!isForeign && vat != null && vat > 0 ? String(roundOre(vat)).replace('.', ',') : '0')
setExpenseAccount('')
setEmployeeId('')
setEmployeeName('')
}, [open, item.id, data, isForeign])
useEffect(() => {
if (!open || payer !== 'employee' || employeesLoaded) return
fetch('/api/salary/employees')
.then((res) => (res.ok ? res.json() : null))
.then((json) => setEmployees((json?.data ?? []) as EmployeeOption[]))
.catch(() => setEmployees([]))
.finally(() => setEmployeesLoaded(true))
}, [open, payer, employeesLoaded])
const amount = parseAmount(amountInput)
// Foreign VAT is never deductible here: the field is locked and 0 is what
// gets submitted, whatever the extraction said.
const vatAmount = isForeign ? 0 : parseAmount(vatInput)
const net = roundOre(amount - vatAmount)
const employee = employees.find((e) => e.id === employeeId) ?? null
const claimantName =
payer === 'owner'
? ownerName.trim() || OWNER_FALLBACK_NAME
: employee
? `${employee.first_name} ${employee.last_name}`.trim()
: ''
const claimantName = payer === 'owner' ? ownerName.trim() || OWNER_FALLBACK_NAME : employeeName
const canSubmit =
!isSubmitting &&
@@ -236,36 +205,18 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
</DialogHeader>
<div className="space-y-4">
{payer === 'owner' ? (
<div className="space-y-1.5">
<Label htmlFor="re-owner">{t('expense_owner_name')}</Label>
<Input
id="re-owner"
value={ownerName}
onChange={(e) => setOwnerName(e.target.value)}
placeholder={OWNER_FALLBACK_NAME}
disabled={isSubmitting}
/>
</div>
) : (
<div className="space-y-1.5">
<Label htmlFor="re-employee">{t('expense_employee')}</Label>
<Select value={employeeId} onValueChange={setEmployeeId} disabled={isSubmitting}>
<SelectTrigger id="re-employee">
<SelectValue
placeholder={employeesLoaded && employees.length === 0 ? t('expense_no_employees') : t('expense_pick_employee')}
/>
</SelectTrigger>
<SelectContent>
{employees.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.first_name} {e.last_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<ExpenseClaimantFields
payer={payer}
ownerName={ownerName}
onOwnerNameChange={setOwnerName}
employeeId={employeeId}
onEmployeeChange={(id, name) => {
setEmployeeId(id)
setEmployeeName(name)
}}
disabled={isSubmitting}
idPrefix="re"
/>
<div className="space-y-1.5">
<Label htmlFor="re-description">{t('expense_description')}</Label>
@@ -56,7 +56,10 @@ import { VatRateCell, RcRateSelect, AmountCell } from '@/components/supplier-inv
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 { PayerChoiceSelect } from '@/components/expenses/PayerChoiceSelect'
import { ExpenseClaimantFields } from '@/components/expenses/ExpenseClaimantFields'
import { isPersonPayer } from '@/lib/expenses/payer'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, AlertTriangle, MessageCircle, 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
@@ -270,7 +273,9 @@ export default function NewSupplierInvoiceForm({
reverse_charge: false,
payment_reference: '',
notes: '',
paid_with_private_funds: false,
payer: 'unpaid',
claimant_name: '',
employee_id: '',
// 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.
@@ -324,7 +329,12 @@ export default function NewSupplierInvoiceForm({
const watchedSupplierId = watch('supplier_id')
const watchedCurrency = watch('currency')
const watchedExchangeRate = watch('exchange_rate')
const watchedPaidPrivately = watch('paid_with_private_funds')
const watchedPayer = watch('payer')
const watchedClaimantName = watch('claimant_name')
const watchedEmployeeId = watch('employee_id')
// A person paid: the invoice is an utlägg (cost + moms against that
// person's liability, an open claim on Hem) instead of a 2440 payable.
const watchedPaidPrivately = isPersonPayer(watchedPayer)
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
@@ -1369,7 +1379,6 @@ export default function NewSupplierInvoiceForm({
showBankPicker,
setShowBankPicker,
setPendingTransactionId,
submitModeRef,
conflict,
setConflict,
isResolvingConflict,
@@ -1393,6 +1402,8 @@ export default function NewSupplierInvoiceForm({
showNoPeriodWarning,
canUseAccrual,
invoiceNumberInputRef,
// The Utlägg nav row is gated on existing claims in the server layout.
onExpenseRegistered: () => router.refresh(),
onMissingField: focusMissingField,
})
@@ -1464,7 +1475,6 @@ export default function NewSupplierInvoiceForm({
const forvalChips: string[] = [
willBookAtRegistration ? t('forval_books_at_registration') : t('forval_books_at_payment'),
]
if (watchedPaidPrivately) forvalChips.push(t('chip_paid_privately'))
if (watchedReverseCharge) forvalChips.push(t('reverse_charge_label'))
if ((watchedCurrency || 'SEK') !== 'SEK') {
forvalChips.push(
@@ -1486,11 +1496,15 @@ export default function NewSupplierInvoiceForm({
? t('underlag_caption_attached')
: t('underlag_caption_default')
// The primary action follows "Vem betalade?": a person -> book the utlägg,
// the company -> register and match the bank line, no one yet -> register.
const primaryLabel = watchedPaidPrivately
? t('register_expense')
: isEF
? t('register_invoice')
: t('review_and_register')
: watchedPayer === 'company'
? t('register_and_mark_paid')
: isEF
? t('register_invoice')
: t('review_and_register')
// min-w-0 on the root: DialogContent is display:grid; without it this grid
// item's min-width:auto lets the kontering table's min-w force the whole
@@ -1801,6 +1815,35 @@ export default function NewSupplierInvoiceForm({
{...register('invoice_date')}
/>
</div>
{/* Vem betalade? The same control as the Underlag pane: the
answer decides the credit account (2440, the bank line or a
person's liability) and the primary action in the footer. */}
<Controller
name="payer"
control={control}
render={({ field }) => (
<PayerChoiceSelect
id="si-payer"
value={field.value}
onChange={field.onChange}
accountingMethod={accountingMethod}
labelClassName="text-xs font-normal text-muted-foreground"
/>
)}
/>
{isPersonPayer(watchedPayer) && (
<ExpenseClaimantFields
payer={watchedPayer}
ownerName={watchedClaimantName}
onOwnerNameChange={(name) => setValue('claimant_name', name, { shouldDirty: true })}
employeeId={watchedEmployeeId}
onEmployeeChange={(id) => setValue('employee_id', id, { shouldDirty: true })}
idPrefix="si"
className="space-y-2"
labelClassName="text-xs font-normal text-muted-foreground"
inputClassName="h-9"
/>
)}
{!watchedPaidPrivately && (
<>
<div className="space-y-2">
@@ -2164,25 +2207,6 @@ export default function NewSupplierInvoiceForm({
</div>
{forvalOpen && (
<div className="mt-4 border-t border-border">
<div className="flex items-center justify-between gap-4 border-b border-border py-3 text-[13px]">
<label htmlFor="paid_with_private_funds" className="cursor-pointer">
{t('paid_privately_label')}
<span className="block text-xs text-muted-foreground">
{isEF ? t('paid_privately_help_ef') : t('paid_privately_help_ab')}
</span>
</label>
<Controller
name="paid_with_private_funds"
control={control}
render={({ field }) => (
<Switch
id="paid_with_private_funds"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
</div>
<div className="flex items-center justify-between gap-4 border-b border-border py-3 text-[13px]">
<label htmlFor="reverse_charge" className="cursor-pointer">
{t('reverse_charge_label')}
@@ -2434,18 +2458,6 @@ export default function NewSupplierInvoiceForm({
>
{t('cancel')}
</button>
{!watchedPaidPrivately && (
<Button
type="submit"
variant="outline"
disabled={isSubmitting || !canWrite}
onClick={() => { submitModeRef.current = 'register_and_match' }}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
<Link2 className="mr-2 h-4 w-4" />
{t('register_and_mark_paid')}
</Button>
)}
{/* Always enabled pre-click for writable users: clicking with
something missing routes focus to the first missing field
(submit-time hard blocks stay in onSubmit). Viewers keep the
@@ -2453,7 +2465,6 @@ export default function NewSupplierInvoiceForm({
<Button
type="submit"
disabled={isSubmitting || !canWrite}
onClick={() => { submitModeRef.current = 'register' }}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isSubmitting ? (
@@ -2514,10 +2525,7 @@ export default function NewSupplierInvoiceForm({
open={showBankPicker}
onOpenChange={(open) => {
setShowBankPicker(open)
if (!open) {
submitModeRef.current = 'register'
setPendingTransactionId(null)
}
if (!open) setPendingTransactionId(null)
}}
targetAmount={total}
targetCurrency={watchedCurrency}
@@ -1,12 +1,13 @@
'use client'
import { useState, useRef, type RefObject } from 'react'
import { useState, type RefObject } from 'react'
import { useTranslations } from 'next-intl'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import { findIllegalVatRateRow } from '@/lib/vat/supplier-invoice-line-checks'
import { rateToPctString, type SupplierInvoiceFormData } from '@/lib/supplier-invoices/form-payload'
import { rateToPctString, supplierInvoiceCreateUrl, type SupplierInvoiceFormData } from '@/lib/supplier-invoices/form-payload'
import { isPersonPayer } from '@/lib/expenses/payer'
import type { InvoiceExtractionResult } from '@/types'
// The existing invoice surfaced on a duplicate-number conflict, used to drive
@@ -22,7 +23,12 @@ export interface ExistingSupplierInvoice {
// envelope's inner object ({ code, message, details }); a few legacy convert
// paths still return a flat string, so accept both.
export interface CreateResult {
data?: { id: string; arrival_number: number }
data?: {
id: string
arrival_number: number
/** Set when the invoice was booked as an utlägg (a person paid). */
expense_claim?: { id: string; claimant_name: string; liability_account: string } | null
}
warnings?: Array<{ code: string; message: string }>
error?:
| string
@@ -50,6 +56,8 @@ interface UseSupplierInvoiceSubmitParams {
showNoPeriodWarning: boolean
canUseAccrual: boolean
invoiceNumberInputRef: RefObject<HTMLInputElement | null>
/** After an utlägg booked: the host refreshes server data (the Utlägg nav gate). */
onExpenseRegistered?: () => void
/**
* Focus router for the always-enabled primary: called instead of a toast
* when a required field is missing (supplier, invoice number, row account),
@@ -85,6 +93,7 @@ export function useSupplierInvoiceSubmit({
showNoPeriodWarning,
canUseAccrual,
invoiceNumberInputRef,
onExpenseRegistered,
onMissingField,
}: UseSupplierInvoiceSubmitParams) {
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -94,11 +103,6 @@ export function useSupplierInvoiceSubmit({
// Match-on-create state
const [showBankPicker, setShowBankPicker] = useState(false)
const [pendingTransactionId, setPendingTransactionId] = useState<string | null>(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<{
@@ -147,8 +151,9 @@ export function useSupplierInvoiceSubmit({
}
}
// Single submit endpoint chooser: convert when we came from inbox, plain
// POST otherwise. Both endpoints validate the same CreateSupplierInvoiceSchema
// Single submit endpoint chooser: convert when we came from inbox and the
// company pays, plain POST otherwise (a person paying is an utlägg, which
// only the core route books). 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: SupplierInvoiceFormData): Promise<{
@@ -156,9 +161,7 @@ export function useSupplierInvoiceSubmit({
status: number
result: CreateResult
}> {
const url = inboxItemId
? `/api/extensions/ext/invoice-inbox/items/${inboxItemId}/convert`
: '/api/supplier-invoices'
const url = supplierInvoiceCreateUrl(data.payer, inboxItemId)
const res = await fetch(url, {
method: 'POST',
@@ -281,18 +284,19 @@ export function useSupplierInvoiceSubmit({
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.
if (data.payer === 'company') {
// "Företaget" is register-and-match: open the bank-transaction picker;
// the 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
// A person paying skips the AB review dialog: the answer 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) {
// against that person's liability account. Same path for EF.
if (isEF || isPersonPayer(data.payer)) {
setPendingData(data)
handleDirectSubmit(data)
} else {
@@ -327,11 +331,21 @@ export function useSupplierInvoiceSubmit({
// beforeunload prompt while we navigate away on a successful submit.
reset(data)
if (data.paid_with_private_funds) {
if (isPersonPayer(data.payer)) {
const claim = result.data.expense_claim
toast({
title: t('expense_registered_title'),
description: t('arrival_number_label', { number: result.data.arrival_number }),
// An enskild firma owner's claim is an egen insättning (2018): no
// debt, so there is no Att göra row to point at.
description:
claim && claim.liability_account !== '2018'
? t('expense_registered_description', {
name: claim.claimant_name,
number: result.data.arrival_number,
})
: t('arrival_number_label', { number: result.data.arrival_number }),
})
onExpenseRegistered?.()
finishCreate()
setIsSubmitting(false)
return
@@ -376,7 +390,6 @@ export function useSupplierInvoiceSubmit({
})
const matchResult = await matchRes.json()
setPendingTransactionId(null)
submitModeRef.current = 'register'
if (matchRes.ok) {
toast({
@@ -534,7 +547,6 @@ export function useSupplierInvoiceSubmit({
})
const matchResult = await matchRes.json()
setIsSubmitting(false)
submitModeRef.current = 'register'
if (matchRes.ok) {
toast({
@@ -560,7 +572,6 @@ export function useSupplierInvoiceSubmit({
showBankPicker,
setShowBankPicker,
setPendingTransactionId,
submitModeRef,
conflict,
setConflict,
isResolvingConflict,