feat(expenses): utlägg as an answer to "Vem betalade?" in Underlag, not a page (#2317)

An out-of-pocket purchase differs from any other receipt only in the
credit account, so the Underlag pane now asks one question for an
unmatched underlag (Företaget / Jag, privat / En anställd / Ingen ännu)
and books a privately paid receipt in place through POST
/api/expense-claims, replacing the "Andra sätt att bokföra" dropdown and
the deep link into the two-step wizard. The verifikat editor stays
reachable below as the escape hatch (BFL 5 kap 6-7 §).

The person owed surfaces in Att göra under a new Betala band, one row per
person (lib/worklist expense_payout, counted in the total and exposed to
agents through the attention resource). The Utlägg nav row is gated on
existing claims, the same hybrid gate as Körjournal, since the entry
point for a new utlägg is now the Underlag pane.


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

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-05 15:57:14 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent bff44e5757
commit cbe5580886
17 changed files with 866 additions and 86 deletions
@@ -1,7 +1,6 @@
'use client'
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
@@ -74,6 +73,7 @@ import {
type InboxKindFilter,
} from '@/lib/documents/inbox-kind'
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
import RegisterExpenseDialog, { type ExpensePayer } from '@/components/extensions/general/RegisterExpenseDialog'
import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog'
// InboxCustomDomainDialog (egen domän) is built but gated off: see
@@ -431,7 +431,6 @@ const WorkspaceSkeleton = InvoiceInboxSkeleton
export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const { toast } = useToast()
const router = useRouter()
const t = useTranslations('inbox_workspace')
const tStart = useTranslations('start_cards')
const dismissKeyCompanyId = useCompanyOptional()?.company?.id ?? null
@@ -494,6 +493,8 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const [isRotating, setIsRotating] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [bookDirectOpen, setBookDirectOpen] = useState(false)
// "Vem betalade?" answered with a person: the utlägg confirm step.
const [registerExpensePayer, setRegisterExpensePayer] = useState<ExpensePayer | null>(null)
// Bulk-book selected underlag (Modell B): the "Bokför valda" selection-bar
// action. The dialog filters the selection to bookable items itself.
const [bulkBookOpen, setBulkBookOpen] = useState(false)
@@ -2155,7 +2156,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
onDelete={() => handleDelete(selected.id)}
onBookDirect={() => setBookDirectOpen(true)}
onCreateSupplierInvoice={() => setCreateSupplierInvoiceOpen(true)}
onRegisterExpense={() => router.push(`/expenses?new=1&inbox_item=${selected.id}`)}
onRegisterExpense={(payer) => setRegisterExpensePayer(payer)}
onMatchTransaction={() => setMatchPickerOpen(true)}
onUnmatchTransaction={async () => {
const targetId = selected.id
@@ -2233,6 +2234,19 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
}}
/>
)}
{selected && registerExpensePayer && (
<RegisterExpenseDialog
open
onOpenChange={(next) => {
if (!next) setRegisterExpensePayer(null)
}}
item={selected}
payer={registerExpensePayer}
onSuccess={async () => {
await Promise.all([fetchItems(), handleSelect(selected.id)])
}}
/>
)}
{selected && (
<NewSupplierInvoiceDialog
open={createSupplierInvoiceOpen}
@@ -3053,6 +3067,68 @@ 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']
export function PayerChoiceList({
value,
onChange,
accountingMethod,
}: {
value: PayerChoice
onChange: (next: PayerChoice) => void
accountingMethod: AccountingMethod
}) {
const t = useTranslations('inbox_workspace')
const helpKey = (choice: PayerChoice): string =>
choice === 'unpaid' && accountingMethod === 'cash' ? 'payer_help_unpaid_cash' : `payer_help_${choice}`
return (
<div className="space-y-1.5">
<p className="text-[13px] font-medium">{t('payer_question')}</p>
<div role="radiogroup" aria-label={t('payer_question')} className="rounded-lg border border-border">
{PAYER_ORDER.map((choice) => {
const selected = choice === value
return (
<button
key={choice}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onChange(choice)}
className={cn(
'flex w-full items-start gap-3 border-b border-border px-3 py-2.5 text-left transition-colors duration-150 last:border-b-0 hover:bg-secondary/30 first:rounded-t-lg last:rounded-b-lg',
selected && 'bg-secondary/40',
)}
>
<span
aria-hidden
className={cn(
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border bg-background',
selected ? 'border-primary' : 'border-input',
)}
>
{selected && <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
</span>
<span className="min-w-0">
<span className="block text-[13px]">{t(`payer_${choice}`)}</span>
<span className="block text-xs text-muted-foreground">{t(helpKey(choice))}</span>
</span>
</button>
)
})}
</div>
</div>
)
}
// ── Fields rail ──────────────────────────────────────────────
function FieldsRail({
@@ -3077,7 +3153,7 @@ function FieldsRail({
onDelete: () => void
onBookDirect: () => void
onCreateSupplierInvoice: () => void
onRegisterExpense: () => void
onRegisterExpense: (payer: ExpensePayer) => void
onMatchTransaction: () => void
onUnmatchTransaction: () => Promise<void>
onAskAssistant?: (transactionId: string) => void
@@ -3135,6 +3211,17 @@ function FieldsRail({
setFieldsExpanded(false)
}, [item.id])
const t = useTranslations('inbox_workspace')
// "Vem betalade?": the one question that decides how an unmatched underlag
// is booked. Company money is matched against the bank line; a person's
// money books cost + moms against that person's liability account and puts
// them in Att göra; "ingen ännu" is an unpaid supplier invoice (2440). A
// supplier invoice defaults to unpaid, a receipt to paid by the company.
const [payer, setPayer] = useState<PayerChoice>(() =>
resolvedKind === 'supplier_invoice' ? 'unpaid' : 'company',
)
useEffect(() => {
setPayer(resolvedKind === 'supplier_invoice' ? 'unpaid' : 'company')
}, [item.id, resolvedKind])
// WhatsApp chat context: verified human answers captured by the intake bot
// (photo caption, representation deltagare + syfte, sender note). Rendered
@@ -3593,61 +3680,31 @@ function FieldsRail({
</>
) : (
<>
{/* Unmatched state: the canonical next step is to find the bank
transaction this underlag belongs to. Two escape hatches sit
below it: "Skapa leverantörsfaktura" for users who want
supplier-invoice tracking (accrual flow), and "Bokför som
verifikat" for underlag that aren't a supplier invoice at all
(bank fees, owner expenses, the underlag for a correction). The
latter opens the same BookDirectlyDialog as the matched state,
which works without a bank transaction and lets the user attach
one if they want. Per BFL 5 kap 6-7 § the underlag must be
bookable as a verifikat, not forced into a supplier invoice. */}
<Button
variant="default"
size="sm"
className="w-full"
onClick={onMatchTransaction}
{/* Unmatched state: one question decides the booking path. Every
answer keeps BFL 5 kap 6-7 § intact (the underlag is booked
as a verifikat, never forced into a supplier invoice), and the
verifikat editor stays reachable below as the escape hatch. */}
<PayerChoiceList value={payer} onChange={setPayer} accountingMethod={accountingMethod} />
{payer === 'company' ? (
<Button variant="default" size="sm" className="w-full" onClick={onMatchTransaction}>
Matcha mot transaktion
</Button>
) : payer === 'unpaid' ? (
<Button variant="default" size="sm" className="w-full" onClick={onCreateSupplierInvoice}>
Skapa leverantörsfaktura
</Button>
) : (
<Button variant="default" size="sm" className="w-full" onClick={() => onRegisterExpense(payer)}>
{t('payer_book_expense')}
</Button>
)}
<button
type="button"
onClick={onBookDirect}
className="w-full text-xs text-muted-foreground hover:text-foreground hover:underline pt-1"
>
Matcha mot transaktion
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="w-full justify-between">
Andra sätt att bokföra
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuItem
onClick={onCreateSupplierInvoice}
className="flex flex-col items-start gap-1"
>
<span>Skapa leverantörsfaktura</span>
<span className="text-xs text-muted-foreground">
För leverantörsskulder du vill följa (periodisering).
</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={onRegisterExpense}
className="flex flex-col items-start gap-1"
>
<span>Registrera som utlägg</span>
<span className="text-xs text-muted-foreground">
För köp du eller en anställd betalat privat.
</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={onBookDirect}
className="flex flex-col items-start gap-1"
>
<span>Bokför som verifikat</span>
<span className="text-xs text-muted-foreground">
För underlag som inte är en leverantörsfaktura (bankavgift, utlägg).
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{t('payer_open_editor')}
</button>
</>
)}
<Button
@@ -0,0 +1,339 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
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 { 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 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'
interface InboxItemLike {
id: string
document_id: string | null
extracted_data: InvoiceExtractionResult | null
}
interface EmployeeOption {
id: string
first_name: string
last_name: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
item: InboxItemLike
payer: ExpensePayer
/** Re-read the item after the claim posted its verifikat. */
onSuccess: () => void | Promise<void>
}
const OWNER_FALLBACK_NAME = 'Ägare'
function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
function parseAmount(raw: string): number {
const n = parseFloat(raw.replace(/\s/g, '').replace(',', '.'))
return Number.isFinite(n) ? n : 0
}
/**
* RegisterExpenseDialog: the confirm step behind "Vem betalade? Jag, privat /
* En anställd" in the Underlag pane. One screen, prefilled from the
* extraction: who, cost account, amount and VAT, then the outcome spelled out
* before anything posts (design convention 10). Posts through
* POST /api/expense-claims, which books cost + moms against the person's
* liability account and stamps the inbox item as booked.
*/
export default function RegisterExpenseDialog({ open, onOpenChange, item, payer, onSuccess }: Props) {
const t = useTranslations('inbox_workspace')
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 data = item.extracted_data
const [description, setDescription] = useState('')
const [expenseDate, setExpenseDate] = useState(todayIso())
const [amountInput, setAmountInput] = useState('')
const [vatInput, setVatInput] = useState('')
const [expenseAccount, setExpenseAccount] = useState('')
const [ownerName, setOwnerName] = useState('')
const [employeeId, setEmployeeId] = useState('')
const [employees, setEmployees] = useState<EmployeeOption[]>([])
const [employeesLoaded, setEmployeesLoaded] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const currency = (data?.invoice?.currency ?? 'SEK').toUpperCase()
// Reset per open so a previous underlag's numbers never carry over.
useEffect(() => {
if (!open) return
setDescription(data?.supplier?.name?.trim() || '')
setExpenseDate(data?.invoice?.invoiceDate || todayIso())
const total = data?.totals?.total
const vat = data?.totals?.vatAmount
setAmountInput(total != null && total > 0 ? String(roundOre(total)).replace('.', ',') : '')
setVatInput(vat != null && vat > 0 ? String(roundOre(vat)).replace('.', ',') : '0')
setExpenseAccount('')
setEmployeeId('')
}, [open, item.id, data])
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)
const vatAmount = 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 canSubmit =
!isSubmitting &&
description.trim().length > 0 &&
ISO_DATE_RE.test(expenseDate) &&
amount > 0 &&
vatAmount >= 0 &&
vatAmount < amount &&
ACCOUNT_NUMBER_RE.test(expenseAccount) &&
/^[4-8]/.test(expenseAccount) &&
(payer === 'owner' || !!employeeId)
const accountName = useMemo(
() => accounts.find((a) => a.account_number === expenseAccount)?.account_name ?? '',
[accounts, expenseAccount],
)
const handleSubmit = useCallback(async () => {
if (!canSubmit) return
setIsSubmitting(true)
try {
const body: Record<string, unknown> = {
description: description.trim(),
expense_date: expenseDate,
amount,
vat_amount: vatAmount,
currency,
expense_account: expenseAccount,
inbox_item_id: item.id,
document_id: item.document_id ?? undefined,
}
if (payer === 'owner') body.claimant_name = claimantName
else body.employee_id = employeeId
const res = await fetch('/api/expense-claims', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = (await res.json().catch(() => ({}))) as {
data?: { journal_entry_id?: string | null }
error?: unknown
}
if (!res.ok) {
toast({
title: t('expense_failed_title'),
description: getErrorMessage(json, { context: 'journal_entry', statusCode: res.status }),
variant: 'destructive',
})
return
}
toast({
title: t('expense_booked_title'),
description: t('expense_booked_description', { name: claimantName }),
})
await onSuccess()
onOpenChange(false)
} finally {
setIsSubmitting(false)
}
}, [
canSubmit,
description,
expenseDate,
amount,
vatAmount,
currency,
expenseAccount,
item.id,
item.document_id,
payer,
claimantName,
employeeId,
toast,
t,
onSuccess,
onOpenChange,
])
return (
<Dialog open={open} onOpenChange={(next) => !isSubmitting && onOpenChange(next)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('expense_dialog_title')}</DialogTitle>
<DialogDescription>
{payer === 'owner'
? t('expense_dialog_help_owner', { account: liabilityAccount })
: t('expense_dialog_help_employee')}
</DialogDescription>
</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>
)}
<div className="space-y-1.5">
<Label htmlFor="re-description">{t('expense_description')}</Label>
<Input
id="re-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label htmlFor="re-date">{t('expense_date')}</Label>
<Input
id="re-date"
type="date"
value={expenseDate}
onChange={(e) => setExpenseDate(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="re-amount">{t('expense_amount', { currency })}</Label>
<Input
id="re-amount"
inputMode="decimal"
value={amountInput}
onChange={(e) => setAmountInput(e.target.value)}
disabled={isSubmitting}
className="tabular-nums"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="re-vat">{t('expense_vat')}</Label>
<Input
id="re-vat"
inputMode="decimal"
value={vatInput}
onChange={(e) => setVatInput(e.target.value)}
disabled={isSubmitting}
className="tabular-nums"
/>
</div>
</div>
<div className="space-y-1.5">
<Label>{t('expense_account')}</Label>
<AccountCombobox
value={expenseAccount}
accounts={accounts}
onChange={setExpenseAccount}
disabled={isSubmitting}
selectedName={accountName}
/>
</div>
{amount > 0 && vatAmount < amount && (
<div className="rounded-lg border border-border px-4 py-3 text-xs text-muted-foreground space-y-1">
<p className="tabular-nums">
{expenseAccount || '____'} D {formatCurrency(net, currency)}
{vatAmount > 0 ? ` · 2641 D ${formatCurrency(vatAmount, currency)}` : ''}
{` · ${liabilityAccount} K ${formatCurrency(amount, currency)}`}
</p>
{claimantName && (
<p>{t('expense_outcome_att_gora', { name: claimantName })}</p>
)}
{currency !== 'SEK' && <p>{t('expense_fx_note')}</p>}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
{t('expense_cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{isSubmitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{t('expense_confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}