Files
accounted/components/transactions/ExpensePayoutMatchDialog.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

91 lines
3.4 KiB
TypeScript

'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>
)
}