Files
accounted/components/extensions/general/RegisterExpenseDialog.tsx
T
b71f2bf425 feat(expenses): repay utlägg from the bank line (#2333)
* feat(expenses): repay utlägg from the bank line

The transfer that repays a person's registered utlägg is now booked from
the bank inbox (or one click on Hem) instead of ahead of it: the payout RPC
takes the unbooked bank transaction, requires an SEK outflow equal to the
claims' total to the öre, posts liability D / 19xx K, marks the claims paid
and links the row in one locked transaction. The same transfer can no
longer be booked twice (once by "Betala ut", once by categorising the row).

- create_expense_payout_batch(..., p_transaction_id): old signature dropped
  so a 6-argument call cannot become ambiguous; refusals TX_NOT_FOUND,
  TX_ALREADY_BOOKED, TX_CURRENCY, TX_AMOUNT_MISMATCH
- POST /api/transactions/[id]/match-expense-payout { claim_ids }
- lib/expenses/expense-payout-candidates: pure per-person grouping and
  outflow pairing (one person per amount; shared totals are skipped)
- Hem suggested matches gain kind 'expense_payout'; the inbox row gets a
  primary "Bokför återbetalning av utlägg till {name}" and a two-leg confirm
- PAYOUT_ERROR_MESSAGES shared by both payout routes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ

* feat(expenses): close the utlägg gaps: claim picker, foreign VAT, enskild firma

- "Matcha mot utlägg" in the inbox row menu: pick the person and the
  receipts a transfer covers when the exact-amount pairing missed it. The
  picked sum must equal the row to the öre; the same RPC books it.
- A foreign receipt defaults VAT to 0 in the Underlag dialog with a note:
  foreign VAT is not deductible on 2641.
- Enskild firma: a claim on 2018 is egen insättning, not a debt. Excluded
  from Att göra, the attention resource, suggestions and the picker; a
  payout for it debits 2013 (eget uttag), never 2018. Copy in the pane and
  the dialog says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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>
2026-09-06 18:16:51 +02:00

354 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)
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'
? 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={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>
{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>
)
}