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>
This commit is contained in:
Jakob Wennberg
2026-09-06 18:16:51 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 238cbe13f9
commit b71f2bf425
26 changed files with 1754 additions and 88 deletions
+9 -3
View File
@@ -166,13 +166,17 @@ export default function AttGoraSection({
? `/api/transactions/${match.transaction_id}/match-invoice`
: match.kind === 'rot_rut_payout'
? `/api/transactions/${match.transaction_id}/match-rot-rut-payout`
: `/api/transactions/${match.transaction_id}/match-supplier-invoice`
: match.kind === 'expense_payout'
? `/api/transactions/${match.transaction_id}/match-expense-payout`
: `/api/transactions/${match.transaction_id}/match-supplier-invoice`
const body =
match.kind === 'invoice'
? { invoice_id: match.candidate_id }
: match.kind === 'rot_rut_payout'
? { request_id: match.candidate_id }
: { supplier_invoice_id: match.candidate_id }
: match.kind === 'expense_payout'
? { claim_ids: match.claim_ids ?? [] }
: { supplier_invoice_id: match.candidate_id }
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -335,7 +339,9 @@ export default function AttGoraSection({
? t('suggested_kind_invoice')
: match.kind === 'rot_rut_payout'
? t('suggested_kind_rot_rut_payout')
: t('suggested_kind_supplier_invoice')}
: match.kind === 'expense_payout'
? t('suggested_kind_expense_payout')
: t('suggested_kind_supplier_invoice')}
{match.candidate_number ? ` ${match.candidate_number}` : ''}
{match.counterparty_name ? ` · ${match.counterparty_name}` : ''}
{' · '}
@@ -3116,15 +3116,20 @@ export function PayerChoiceSelect({
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 === 'unpaid' && accountingMethod === 'cash'
? 'payer_help_unpaid_cash'
: `payer_help_${choice}`
: 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">
@@ -103,6 +103,11 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
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(() => {
@@ -112,10 +117,10 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
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')
setVatInput(!isForeign && vat != null && vat > 0 ? String(roundOre(vat)).replace('.', ',') : '0')
setExpenseAccount('')
setEmployeeId('')
}, [open, item.id, data])
}, [open, item.id, data, isForeign])
useEffect(() => {
if (!open || payer !== 'employee' || employeesLoaded) return
@@ -221,7 +226,9 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
<DialogTitle>{t('expense_dialog_title')}</DialogTitle>
<DialogDescription>
{payer === 'owner'
? t('expense_dialog_help_owner', { account: liabilityAccount })
? isEf
? t('expense_dialog_help_owner_ef')
: t('expense_dialog_help_owner', { account: liabilityAccount })
: t('expense_dialog_help_employee')}
</DialogDescription>
</DialogHeader>
@@ -321,10 +328,12 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
{vatAmount > 0 ? ` · 2641 D ${formatCurrency(vatAmount, currency)}` : ''}
{` · ${liabilityAccount} K ${formatCurrency(amount, currency)}`}
</p>
{claimantName && (
<p>{t('expense_outcome_att_gora', { name: claimantName })}</p>
{payer === 'owner' && isEf ? (
<p>{t('expense_outcome_ef')}</p>
) : (
claimantName && <p>{t('expense_outcome_att_gora', { name: claimantName })}</p>
)}
{currency !== 'SEK' && <p>{t('expense_fx_note')}</p>}
{isForeign && <p>{t('expense_fx_note')}</p>}
</div>
)}
</div>
@@ -0,0 +1,224 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { groupExpenseClaimsByPerson } from '@/lib/expenses/expense-payout-candidates'
import type { ExpensePayoutDue } from '@/lib/worklist/types'
import type { TransactionWithInvoice } from './transaction-types'
interface ClaimRow {
id: string
employee_id: string | null
claimant_name: string
liability_account: string
amount_sek: number | string
expense_date: string
description: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
onMatched: (transactionId: string, journalEntryId: string, personKey: string) => void
}
/**
* Manual "Matcha mot utlägg": for an outflow that the exact-amount suggestion
* did not pair (two receipts of three were paid, or the amount covers a
* subset), the user picks the person and the receipts the transfer covers.
* The sum must equal the transfer to the öre: the same RPC as the one-click
* path books it, so a partial receipt can never be marked paid.
*/
export default function ExpenseClaimPickerDialog({ open, onOpenChange, transaction, onMatched }: Props) {
const t = useTranslations('tx_expense_claim_picker')
const { toast } = useToast()
const [claims, setClaims] = useState<ClaimRow[]>([])
const [loading, setLoading] = useState(false)
const [personKey, setPersonKey] = useState<string>('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [submitting, setSubmitting] = useState(false)
const transferOre = transaction ? Math.round(Math.abs(transaction.amount) * 100) : 0
useEffect(() => {
if (!open) return
let cancelled = false
setLoading(true)
setSelected(new Set())
fetch('/api/expense-claims?status=registered')
.then((res) => (res.ok ? res.json() : { data: [] }))
.then((json) => {
if (cancelled) return
const rows = ((json?.data ?? []) as ClaimRow[]).filter((r) => r.liability_account !== '2018')
setClaims(rows)
const people = groupExpenseClaimsByPerson(rows)
// Preselect the person (and all their receipts) when one person's
// total equals the transfer; otherwise the first person, nothing ticked.
const exact = people.find((p) => Math.round(p.total_sek * 100) === transferOre)
const first = exact ?? people[0]
setPersonKey(first?.key ?? '')
setSelected(new Set(exact ? exact.claim_ids : []))
})
.catch(() => {
if (!cancelled) setClaims([])
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [open, transferOre])
const people: ExpensePayoutDue[] = useMemo(() => groupExpenseClaimsByPerson(claims), [claims])
const personClaims = useMemo(
() =>
claims
.filter((c) => (c.employee_id ?? `owner:${c.claimant_name}`) === personKey)
.sort((a, b) => a.expense_date.localeCompare(b.expense_date)),
[claims, personKey],
)
const selectedOre = personClaims
.filter((c) => selected.has(c.id))
.reduce((sum, c) => sum + Math.round((Number(c.amount_sek) || 0) * 100), 0)
const diff = roundOre((selectedOre - transferOre) / 100)
const canConfirm = !submitting && !loading && selected.size > 0 && selectedOre === transferOre
const toggle = (id: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(id)
else next.delete(id)
return next
})
}
const handleConfirm = async () => {
if (!transaction || !canConfirm) return
setSubmitting(true)
try {
const res = await fetch(`/api/transactions/${transaction.id}/match-expense-payout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ claim_ids: [...selected] }),
})
const result = await res.json().catch(() => ({}))
if (!res.ok) {
toast({
title: t('failed_title'),
description: getErrorMessage(result, { context: 'transaction', statusCode: res.status }),
variant: 'destructive',
})
return
}
onMatched(transaction.id, result.journal_entry_id, personKey)
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={(next) => !submitting && onOpenChange(next)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
</div>
) : people.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">{t('no_claims')}</p>
) : (
<div className="space-y-4">
{people.length > 1 && (
<div className="space-y-1.5">
<Label>{t('person_label')}</Label>
<Select
value={personKey}
onValueChange={(next) => {
setPersonKey(next)
setSelected(new Set())
}}
>
<SelectTrigger className="h-9 text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{people.map((p) => (
<SelectItem key={p.key} value={p.key}>
{p.claimant_name} · {formatCurrency(p.total_sek)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="rounded-lg border border-border">
{personClaims.map((c) => (
<label
key={c.id}
className="flex cursor-pointer items-center gap-3 border-b border-border px-3 py-2.5 text-[13px] last:border-b-0 hover:bg-secondary/30"
>
<Checkbox
checked={selected.has(c.id)}
onCheckedChange={(checked) => toggle(c.id, checked === true)}
disabled={submitting}
/>
<span className="w-[86px] shrink-0 tabular-nums text-muted-foreground">{formatDate(c.expense_date)}</span>
<span className="min-w-0 flex-1 truncate">{c.description}</span>
<span className="shrink-0 tabular-nums">{formatCurrency(Number(c.amount_sek))}</span>
</label>
))}
</div>
<div className="flex items-baseline justify-between text-xs text-muted-foreground tabular-nums">
<span>{t('selected_sum', { amount: formatCurrency(selectedOre / 100) })}</span>
<span>{t('transfer_sum', { amount: formatCurrency(transferOre / 100) })}</span>
</div>
{selected.size > 0 && diff !== 0 && (
<p className="text-xs text-attn tabular-nums">{t('diff', { amount: formatCurrency(diff) })}</p>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
{t('cancel')}
</Button>
<Button onClick={handleConfirm} disabled={!canConfirm}>
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{t('confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,90 @@
'use client'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { formatCurrency, formatDate } from '@/lib/utils'
import { Loader2 } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
interface ExpensePayoutMatchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Row carrying `potential_expense_payout`; the dialog renders nothing without it. */
transaction: TransactionWithInvoice | null
isConfirming: boolean
onConfirm: () => void
}
/**
* Confirm dialog for booking an outgoing bank row as the repayment of one
* person's registered utlägg: the person's liability account is debited,
* the row's cash account credited, the claims flip to paid and the row is
* linked to the voucher, all in one RPC call. Two legs, amount from the bank
* row, so everything to approve is known up front (convention 10).
*/
export default function ExpensePayoutMatchDialog({
open,
onOpenChange,
transaction,
isConfirming,
onConfirm,
}: ExpensePayoutMatchDialogProps) {
const t = useTranslations('tx_expense_payout_match')
const match = transaction?.potential_expense_payout ?? null
const currency = transaction?.currency || 'SEK'
const amount = transaction ? Math.abs(transaction.amount) : 0
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
{transaction && match && (
<div className="space-y-4">
<div className="rounded-lg border border-border p-4 space-y-1">
<p className="font-medium">{transaction.description}</p>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground tabular-nums">{formatDate(transaction.date)}</span>
<span className="font-medium tabular-nums">{formatCurrency(-amount, currency)}</span>
</div>
</div>
<div className="rounded-lg border border-border p-4 space-y-1 text-sm">
<p className="font-medium">{match.claimant_name}</p>
<p className="text-muted-foreground">
{match.claim_count === 1
? t('claims_one', { date: formatDate(match.oldest_expense_date) })
: t('claims_other', { count: match.claim_count, date: formatDate(match.oldest_expense_date) })}
</p>
<p className="text-xs text-muted-foreground tabular-nums pt-2 border-t border-border">
{match.liability_account} D {formatCurrency(amount, 'SEK')} · 19xx K {formatCurrency(amount, 'SEK')}
</p>
<p className="text-xs text-muted-foreground">{t('outcome')}</p>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isConfirming}>
{t('cancel')}
</Button>
<Button onClick={onConfirm} disabled={isConfirming || !match}>
{isConfirming && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{t('confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -72,6 +72,10 @@ interface TransactionInboxCardProps {
/** Open the existing-verifikat matcher: link the bank tx to an already-booked
* voucher (salary, Fortnox import, manual entry) with no new bokföring. */
onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void
/** Open the utlägg picker: book this outflow as the repayment of chosen
* registered claims (sum must equal the row). Passed only while the company
* has open claims, so the item never shows for the companies without any. */
onOpenMatchExpense?: (transaction: TransactionWithInvoice) => void
/** Open the attach-underlag dialog: pin an inbox document or a fresh upload
* to the transaction (the tx→doc mirror of the Documents view's matcher). */
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
@@ -116,6 +120,7 @@ export default function TransactionInboxCard({
onOpenMatchInvoicePicker,
onOpenSplitMatch,
onOpenMatchVoucher,
onOpenMatchExpense,
onOpenAttachDocument,
onDetachDocument,
onOpenCategoryDialog,
@@ -184,6 +189,9 @@ export default function TransactionInboxCard({
// Skatteverkets ROT/RUT-utbetalning for an open begäran: same 1-click
// shortcut as an invoice match, confirmed in its own dialog.
const hasRotRutPayoutMatch = !!transaction.potential_rot_rut_payout && !transaction.journal_entry_id
// A transfer that repays one person's registered utlägg to the öre: same
// 1-click shortcut, confirmed in its own dialog.
const hasExpensePayoutMatch = !!transaction.potential_expense_payout && !transaction.journal_entry_id
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
const selectable = isUncategorized && canWrite
// Unbooked rows are still actionable (match, split, edit, categorize): that
@@ -207,7 +215,9 @@ export default function TransactionInboxCard({
})
: hasRotRutPayoutMatch
? t('match_rot_rut_payout_btn', { name: transaction.potential_rot_rut_payout!.name })
: null
: hasExpensePayoutMatch
? t('match_expense_payout_btn', { name: transaction.potential_expense_payout!.claimant_name })
: null
// Primary action: invoice/supplier-invoice match keeps the 1-click
// shortcut; otherwise the user opens the template picker. Rendered as the
@@ -221,7 +231,7 @@ export default function TransactionInboxCard({
// Manual invoice-match affordance. Hidden once an auto-detected match is
// already shown as the primary button: having both makes the row noisy.
const showInvoiceMatchButton =
isUnbooked && !hasInvoiceMatch && !hasSupplierInvoiceMatch && !hasRotRutPayoutMatch
isUnbooked && !hasInvoiceMatch && !hasSupplierInvoiceMatch && !hasRotRutPayoutMatch && !hasExpensePayoutMatch
const invoiceMatchLabel = isIncome
? 'Matcha mot kundfaktura'
@@ -238,6 +248,7 @@ export default function TransactionInboxCard({
// invoice match was auto-detected: the user may want to point the bank line at
// an existing salary/Fortnox/manual voucher instead of confirming a payment.
const showMatchVoucherItem = isUnbooked && !!onOpenMatchVoucher
const showMatchExpenseItem = isUnbooked && !isIncome && !!onOpenMatchExpense && !hasExpensePayoutMatch
// "Matcha mot underlag": pin an inbox doc / fresh upload to the tx. The
// tx→doc mirror of the Documents view's "Matcha mot transaktion".
const showAttachDocumentItem = isUnbooked && canWrite && !!onOpenAttachDocument
@@ -260,7 +271,7 @@ export default function TransactionInboxCard({
const showIgnoreItem = isUnbooked && isImportedTransaction(transaction) && !!onIgnore
const showDeleteItem = canDelete && !!onDelete
const showOverflowMenu =
showInvoiceMatchButton || showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showMoveAccountItem || showIgnoreItem || showDeleteItem
showInvoiceMatchButton || showMatchVoucherItem || showMatchExpenseItem || showAttachDocumentItem || showSplitItem || showEditItem || showMoveAccountItem || showIgnoreItem || showDeleteItem
// Pre-migration history row (ISO dates compare lexically): most likely
// corresponds to an already-imported verifikat, so it carries a quiet
@@ -444,6 +455,17 @@ export default function TransactionInboxCard({
{t('match_voucher_btn')}
</DropdownMenuItem>
)}
{showMatchExpenseItem && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onOpenMatchExpense!(transaction)
}}
>
<FileSearch className="h-4 w-4" />
{t('match_expense_btn')}
</DropdownMenuItem>
)}
{showAttachDocumentItem && (
<DropdownMenuItem
onClick={(e) => {
@@ -1,5 +1,6 @@
import type { Transaction, TransactionCategory, Invoice, Customer, SupplierInvoice, VatTreatment } from '@/types'
import type { RotRutPayoutRequestCandidate } from '@/lib/invoices/rot-rut-payout-matching'
import type { ExpensePayoutDue } from '@/lib/worklist/types'
/** Open ROT/RUT begäran hung onto an income row as a match suggestion, with
* the invoices it covers (so the user sees which fakturor the payout settles). */
@@ -23,6 +24,10 @@ export interface TransactionWithInvoice extends Transaction {
potential_supplier_invoice?: SupplierInvoice
potential_rot_rut_payout?: PotentialRotRutPayout
potential_voucher?: PotentialVoucher
/** The person whose registered utlägg this outflow repays in full
* (lib/expenses/expense-payout-candidates): computed at read time, no
* hint column. Present only while their claims are still registered. */
potential_expense_payout?: ExpensePayoutDue
}
// Page view modes. 'review' is the migrator surface: rows whose sweep