Files
accounted/components/extensions/general/RegisterExpenseDialog.tsx
T
3b9daf2606 fix(expenses): review follow-ups from #2333 (#2352)
- The owner's claimant key is trimmed and lower-cased, the same rule the
  payout RPC applies, so "Jakob" and "jakob " are one person with one
  Att göra row and one exact-amount match.
- The inbox pages through every registered claim (fetchAllRows) before
  pairing, so a long backlog can never understate a person's debt.
- A foreign receipt's VAT field is locked at 0 and 0 is what is submitted.
- Transport failures in the one-click and picker confirms show the
  destructive toast instead of failing silently.
- The open-claims flag is set only after the stale-fetch guard, and a
  payout match decrements the inbox count like every other row exit.
- Test: reset the live-link mock before the bank_line junction case.
- Wording: "Återbetalning av utlägg".

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:19:18 +02:00

356 lines
12 KiB
TypeScript

'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 {
// Local calendar date: toISOString() is UTC and would date a receipt booked
// after midnight CEST to the previous day (wrong period, wrong FX rate).
const now = new Date()
const mm = String(now.getMonth() + 1).padStart(2, '0')
const dd = String(now.getDate()).padStart(2, '0')
return `${now.getFullYear()}-${mm}-${dd}`
}
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()
// A foreign receipt carries VAT the company cannot deduct on 2641: the whole
// amount is cost. The wizard asked for the seller's country; here the
// currency is the signal, and the note under the preview says so.
const isForeign = currency !== 'SEK'
const isEf = entityType === 'enskild_firma'
// 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(!isForeign && vat != null && vat > 0 ? String(roundOre(vat)).replace('.', ',') : '0')
setExpenseAccount('')
setEmployeeId('')
}, [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 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'
? isEf
? t('expense_dialog_help_owner_ef')
: 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={isForeign ? '0' : vatInput}
onChange={(e) => setVatInput(e.target.value)}
disabled={isSubmitting || isForeign}
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>
{payer === 'owner' && isEf ? (
<p>{t('expense_outcome_ef')}</p>
) : (
claimantName && <p>{t('expense_outcome_att_gora', { name: claimantName })}</p>
)}
{isForeign && <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>
)
}