feat(rot-rut): match Skatteverket's payout against the begäran from the bank row (#2271)

* feat(rot-rut): match Skatteverket's payout against the begäran from the bank row

A ROT/RUT invoice is stored with remaining_amount net of the deduction, so
once the customer pays it flips to paid and drops out of the matchable set.
Skatteverket's payout for the 1513 share then lands as an income row with no
candidate: the only clearing path was a headless settle endpoint that never
linked the bank row.

The candidate is the payout request (one lump sum per begäran, possibly
covering several invoices), modelled exactly like the supplier-invoice hint:

- migration 20260904020000: transactions.potential_rot_rut_payout_request_id
- pure matcher (exact amount vs decided_total ?? requested_total, boosted
  when Skatteverket is named, ambiguous when two requests share the amount)
- hint written at bank ingest and by batch-match-invoices; cleared by the
  link and reconciliation paths and by clearSettledInvoiceSuggestions
- shared settle service (lib/invoices/rot-rut-settle.ts) used by the existing
  settle route and the new POST /api/transactions/[id]/match-rot-rut-payout,
  which books debit 19xx / credit 1513 and links the row in one call
- transactions inbox pill, own confirm dialog listing the covered invoices,
  manual fallback section in the invoice picker, worklist and Att göra rows

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(rot-rut): cap the payout at the begäran, CAS on the request and on stale pointers

Skeptic findings on 6aa7b2e5c:
- a bank row larger than the begäran was booked in full, driving 1513 into
  a credit balance and rewriting decided_total to the bank amount: refuse
  amount > decided_total ?? requested_total in the service and block the
  dialog's confirm with the reason
- two concurrent settles could both attach and credit 1513 twice: the
  request update now locks on settlement_journal_entry_id IS NULL and the
  loser returns ROT_RUT_SETTLE_RACE (409) with its orphan voucher id
- a row with a stale (reversed) journal_entry_id passed the route guard but
  always lost the null-only link CAS: the route forwards the pointer it read
  and the service locks on that value, as link-journal-entry does
- the pinned underlag on the bank row now propagates onto the voucher

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(rot-rut): review round: SEK gate, voucher-less paid matchable, hint-write errors, one live voucher per begäran

CodeRabbit findings on a93dc46b8, one batch:
- picker and dialog only offer a begäran to SEK rows (the route refuses
  other currencies, so the manual flow no longer dead-ends)
- a voucher-less `paid` request (beslut recorded via PATCH, money not yet
  booked) is matchable; settled means a settlement voucher exists
- ingest and batch-match check the hint update's error before draining the
  pool or counting the match
- the invoice.match_confirmed payload clears the payout hint like the row
- migration 20260904021000: partial unique index on journal_entries
  (company_id, source_id) for live rot_rut_payout entries, so two racing
  settles cannot both book a voucher; pg test included

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5
Signed-off-by: Emil <emilmattsson14@gmail.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-04 14:33:41 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 82859d01db
commit 50b6299699
34 changed files with 2350 additions and 147 deletions
+9 -3
View File
@@ -159,11 +159,15 @@ export default function AttGoraSection({
const url =
match.kind === 'invoice'
? `/api/transactions/${match.transaction_id}/match-invoice`
: `/api/transactions/${match.transaction_id}/match-supplier-invoice`
: match.kind === 'rot_rut_payout'
? `/api/transactions/${match.transaction_id}/match-rot-rut-payout`
: `/api/transactions/${match.transaction_id}/match-supplier-invoice`
const body =
match.kind === 'invoice'
? { invoice_id: match.candidate_id }
: { supplier_invoice_id: match.candidate_id }
: match.kind === 'rot_rut_payout'
? { request_id: match.candidate_id }
: { supplier_invoice_id: match.candidate_id }
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -323,7 +327,9 @@ export default function AttGoraSection({
<ArrowRight className="inline h-3 w-3 mr-1" aria-hidden />
{match.kind === 'invoice'
? t('suggested_kind_invoice')
: t('suggested_kind_supplier_invoice')}
: match.kind === 'rot_rut_payout'
? t('suggested_kind_rot_rut_payout')
: t('suggested_kind_supplier_invoice')}
{match.candidate_number ? ` ${match.candidate_number}` : ''}
{match.counterparty_name ? ` · ${match.counterparty_name}` : ''}
{' · '}
+117 -6
View File
@@ -5,30 +5,56 @@ import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Input } from '@/components/ui/input'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import { Search, FileText, Loader2 } from 'lucide-react'
import { roundOre } from '@/lib/money'
import { Search, FileText, Loader2, Landmark } from 'lucide-react'
import { useCompany } from '@/contexts/CompanyContext'
import type { Invoice, Customer } from '@/types'
import type { TransactionWithInvoice } from './transaction-types'
import type { PotentialRotRutPayout, TransactionWithInvoice } from './transaction-types'
import {
DOMESTIC_CURRENCY,
normalizeCurrency,
rankInvoicesByAmountProximity,
} from './invoice-candidate-ranking'
import {
expectedRotRutPayoutAmount,
OPEN_ROT_RUT_PAYOUT_STATUSES,
} from '@/lib/invoices/rot-rut-payout-matching'
type OpenInvoice = Invoice & { customer?: Customer }
interface InvoicePickerProps {
transaction: TransactionWithInvoice
onSelect: (invoice: OpenInvoice) => void
/** Pick an open ROT/RUT begäran instead of an invoice (Skatteverkets
* utbetalning). The section only renders when the company has one. */
onSelectRotRutPayout?: (request: PotentialRotRutPayout) => void
}
export default function InvoicePicker({ transaction, onSelect }: InvoicePickerProps) {
type RotRutRequestRow = {
id: string
name: string
deduction_type: 'rot' | 'rut'
status: string
requested_total: number | string
decided_total: number | string | null
settlement_journal_entry_id: string | null
items?: Array<{
requested_amount: number | string
invoice?: { invoice_number: string | null } | { invoice_number: string | null }[] | null
}> | null
}
export default function InvoicePicker({ transaction, onSelect, onSelectRotRutPayout }: InvoicePickerProps) {
const t = useTranslations('tx_invoice_picker')
const { company } = useCompany()
const supabase = useMemo(() => createClient(), [])
const [invoices, setInvoices] = useState<OpenInvoice[]>([])
const [rotRutRequests, setRotRutRequests] = useState<PotentialRotRutPayout[]>([])
const [isLoading, setIsLoading] = useState(true)
const [search, setSearch] = useState('')
// Boolean, not the callback: a fresh function identity per parent render
// must not refetch the list.
const wantRotRutRequests = !!onSelectRotRutPayout
useEffect(() => {
if (!company) return
@@ -82,6 +108,37 @@ export default function InvoicePicker({ transaction, onSelect }: InvoicePickerPr
visible = all.filter((inv) => !paidSet.has(inv.id))
}
// Open ROT/RUT begäran: the manual fallback when the SKV payout got no
// auto-hint (amount differs from the request, or the row predates the
// hint). Non-fatal: the invoice list renders either way.
if (wantRotRutRequests) {
const { data: requests } = await supabase
.from('rot_rut_payout_requests')
.select(
'id, name, deduction_type, status, requested_total, decided_total, settlement_journal_entry_id, items:rot_rut_payout_request_items(requested_amount, invoice:invoices(invoice_number))',
)
.eq('company_id', companyId)
.in('status', [...OPEN_ROT_RUT_PAYOUT_STATUSES])
.is('settlement_journal_entry_id', null)
.order('created_at', { ascending: false })
if (cancelled) return
setRotRutRequests(
((requests as RotRutRequestRow[] | null) ?? []).map((req) => ({
id: req.id,
name: req.name,
deduction_type: req.deduction_type,
status: req.status,
requested_total: req.requested_total,
decided_total: req.decided_total,
settlement_journal_entry_id: req.settlement_journal_entry_id,
invoices: (req.items ?? []).map((item) => {
const inv = Array.isArray(item.invoice) ? item.invoice[0] : item.invoice
return { invoice_number: inv?.invoice_number ?? null, requested_amount: item.requested_amount }
}),
})),
)
}
setInvoices(visible)
setIsLoading(false)
}
@@ -89,7 +146,7 @@ export default function InvoicePicker({ transaction, onSelect }: InvoicePickerPr
return () => {
cancelled = true
}
}, [company, supabase])
}, [company, supabase, wantRotRutRequests])
const sorted = useMemo(() => {
const filtered = !search
@@ -122,16 +179,70 @@ export default function InvoicePicker({ transaction, onSelect }: InvoicePickerPr
)
}
const txAmount = roundOre(transaction.amount)
// Skatteverket pays out in kronor only; the match route refuses other
// currencies, so a foreign-currency row must not be offered a begäran.
const txIsSek = (transaction.currency || 'SEK').toUpperCase() === 'SEK'
const rotRutSection =
onSelectRotRutPayout && txIsSek && rotRutRequests.length > 0 ? (
<div className="space-y-1.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('rot_rut_section_title')}
</p>
{rotRutRequests.map((request) => {
const expected = expectedRotRutPayoutAmount(request)
const exact = Math.abs(expected - txAmount) < 0.005
return (
<button
key={request.id}
type="button"
onClick={() => onSelectRotRutPayout(request)}
className={cn(
'w-full text-left rounded-lg border px-3 py-2.5 transition-colors',
'hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring',
exact && 'border-success/50 bg-success/5',
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Landmark className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="font-medium text-sm">{request.name}</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{t('rot_rut_request_meta', {
type: request.deduction_type === 'rut' ? 'RUT' : 'ROT',
count: request.invoices.length,
})}
</p>
</div>
<div className="text-right flex-shrink-0">
<p className={cn('text-sm font-medium tabular-nums', exact && 'text-success')}>
{formatCurrency(expected, DOMESTIC_CURRENCY)}
</p>
{exact && <p className="text-[10px] text-success">{t('exact_match')}</p>}
</div>
</div>
</button>
)
})}
</div>
) : null
if (invoices.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">{t('empty')}</p>
<div className="space-y-3">
{rotRutSection}
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">{t('empty')}</p>
</div>
</div>
)
}
return (
<div className="space-y-3">
{rotRutSection}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
@@ -189,6 +189,7 @@ export default function QuickReviewDialog({
setEnrichedTx({ ...json.data, ...{
potential_invoice: transaction.potential_invoice,
potential_supplier_invoice: transaction.potential_supplier_invoice,
potential_rot_rut_payout: transaction.potential_rot_rut_payout,
} })
}
} catch {
@@ -0,0 +1,212 @@
'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 { roundOre } from '@/lib/money'
import {
expectedRotRutPayoutAmount,
getRotRutPayoutMatchTargetState,
} from '@/lib/invoices/rot-rut-payout-matching'
import { CheckCircle2, AlertTriangle } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
interface RotRutPayoutMatchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Row carrying `potential_rot_rut_payout`; the dialog renders nothing without it. */
transaction: TransactionWithInvoice | null
isConfirming: boolean
onConfirm: () => void
}
/**
* Confirm dialog for matching an income bank row to an open ROT/RUT begäran:
* Skatteverkets utbetalning clears the 1513 receivable (debit the row's cash
* account, credit 1513) and the row is linked to that voucher.
*
* Kept separate from InvoiceMatchDialog on purpose: no FX, no preview fetch,
* no editable lines. The entry has exactly two legs and the amount is the
* bank row's, so everything the user needs to approve is known up front.
*/
export default function RotRutPayoutMatchDialog({
open,
onOpenChange,
transaction,
isConfirming,
onConfirm,
}: RotRutPayoutMatchDialogProps) {
const t = useTranslations('tx_rot_rut_match')
const request = transaction?.potential_rot_rut_payout ?? null
const targetState = getRotRutPayoutMatchTargetState(request)
const targetBlocked = targetState !== 'matchable'
const txAmount = transaction ? roundOre(transaction.amount) : 0
const expected = request ? expectedRotRutPayoutAmount(request) : 0
const requestedTotal = request ? roundOre(Number(request.requested_total)) : 0
const diff = roundOre(Math.abs(txAmount - expected))
const amountsMatch = diff < 0.01
// The settle service refuses a payout below requested_total unless the
// beslut (decided_total) is recorded: say so here instead of letting the
// button fail.
const isPartial = request ? txAmount < requestedTotal - 0.005 : false
const partialBlocked = isPartial && request?.decided_total == null
// The service refuses more than Skatteverket can owe on this begäran: a
// larger row would drive 1513 negative. Block here too, with the reason.
const overBlocked = request ? txAmount > expected + 0.005 : false
// Skatteverket pays out in SEK only; the route refuses anything else.
const currencyBlocked = (transaction?.currency || 'SEK').toUpperCase() !== 'SEK'
const currency = transaction?.currency || 'SEK'
const typeLabel = request?.deduction_type === 'rut' ? 'RUT' : 'ROT'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>
{targetBlocked ? t('description_blocked') : t('description')}
</DialogDescription>
</DialogHeader>
{transaction && request && (
<div className="space-y-4">
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">{t('transaction_label')}</p>
<p className="font-medium">{transaction.description}</p>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{formatDate(transaction.date)}</span>
<span className="font-medium text-success">
+{formatCurrency(transaction.amount, currency)}
</span>
</div>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">{t('request_label')}</p>
<p className="font-medium">{t('request_name', { type: typeLabel, name: request.name })}</p>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t('requested_total', { amount: formatCurrency(requestedTotal, 'SEK') })}
</span>
{request.decided_total != null && (
<span className="text-muted-foreground">
{t('decided_total', { amount: formatCurrency(Number(request.decided_total), 'SEK') })}
</span>
)}
</div>
{request.invoices.length > 0 && (
<div className="pt-2 border-t space-y-1">
<p className="text-xs font-medium text-muted-foreground">{t('invoices_title')}</p>
<ul className="text-sm space-y-0.5">
{request.invoices.map((inv, i) => (
<li key={`${inv.invoice_number ?? 'x'}-${i}`} className="flex justify-between">
<span>{t('invoice_row', { number: inv.invoice_number ?? '' })}</span>
<span className="tabular-nums text-muted-foreground">
{formatCurrency(Number(inv.requested_amount), 'SEK')}
</span>
</li>
))}
</ul>
</div>
)}
</div>
{targetBlocked ? (
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/30 text-attn">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium">
{t(targetState === 'settled' ? 'target_settled_title' : 'target_not_open_title')}
</p>
<p>
{t(
targetState === 'settled'
? 'target_settled_description'
: 'target_not_open_description',
)}
</p>
</div>
</div>
) : amountsMatch ? (
<div className="flex items-center gap-2 p-3 rounded-lg bg-success/10 text-success">
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<p className="text-sm font-medium">{t('amounts_match')}</p>
</div>
) : (
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/30 text-attn">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium">{t('amounts_differ')}</p>
<p>{t('amount_diff', { amount: formatCurrency(diff, currency) })}</p>
{overBlocked && <p className="mt-1">{t('over_payout_blocked')}</p>}
{partialBlocked && <p className="mt-1">{t('partial_requires_beslut')}</p>}
{isPartial && !partialBlocked && (
<p className="mt-1">{t('partial_with_beslut_note')}</p>
)}
</div>
</div>
)}
{!targetBlocked && (
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium">{t('booking_title')}</p>
<div className="text-sm space-y-1">
<div className="flex justify-between">
<span>
<span className="text-muted-foreground">{t('booking_debit')}</span>{' '}
{t('booking_bank_line')}
</span>
<span className="tabular-nums">{formatCurrency(txAmount, currency)}</span>
</div>
<div className="flex justify-between">
<span>
<span className="text-muted-foreground">{t('booking_credit')}</span>{' '}
{t('booking_receivable_line')}
</span>
<span className="tabular-nums">{formatCurrency(txAmount, currency)}</span>
</div>
</div>
</div>
)}
{!targetBlocked && (
<div className="rounded-lg bg-muted/50 p-4 space-y-2">
<p className="text-sm font-medium">{t('on_confirm_title')}</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• {t('on_confirm_link')}</li>
<li>• {t('on_confirm_request')}</li>
<li>• {t('on_confirm_voucher')}</li>
</ul>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isConfirming}>
{t('cancel')}
</Button>
<Button
onClick={onConfirm}
disabled={
isConfirming || !request || targetBlocked || partialBlocked || overBlocked || currencyBlocked
}
>
{isConfirming ? t('confirming') : t('confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -181,6 +181,9 @@ export default function TransactionInboxCard({
const hasInvoiceMatch = !!transaction.potential_invoice && !transaction.invoice_id
const hasSupplierInvoiceMatch =
!!transaction.potential_supplier_invoice && !transaction.supplier_invoice_id
// 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
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
const selectable = isUncategorized && canWrite
// Unbooked rows are still actionable (match, split, edit, categorize): that
@@ -202,7 +205,9 @@ export default function TransactionInboxCard({
? t('match_supplier_invoice_btn', {
number: transaction.potential_supplier_invoice!.supplier_invoice_number ?? '',
})
: null
: hasRotRutPayoutMatch
? t('match_rot_rut_payout_btn', { name: transaction.potential_rot_rut_payout!.name })
: null
// Primary action: invoice/supplier-invoice match keeps the 1-click
// shortcut; otherwise the user opens the template picker. Rendered as the
@@ -216,7 +221,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
isUnbooked && !hasInvoiceMatch && !hasSupplierInvoiceMatch && !hasRotRutPayoutMatch
const invoiceMatchLabel = isIncome
? 'Matcha mot kundfaktura'
@@ -1,4 +1,11 @@
import type { Transaction, TransactionCategory, Invoice, Customer, SupplierInvoice, VatTreatment } from '@/types'
import type { RotRutPayoutRequestCandidate } from '@/lib/invoices/rot-rut-payout-matching'
/** 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). */
export interface PotentialRotRutPayout extends RotRutPayoutRequestCandidate {
invoices: Array<{ invoice_number: string | null; requested_amount: number | string }>
}
/** Revalidated journal-entry match suggestion hung onto a row (mirrors
* potential_invoice): present only when the suggested entry is still posted. */
@@ -14,6 +21,7 @@ export interface PotentialVoucher {
export interface TransactionWithInvoice extends Transaction {
potential_invoice?: Invoice & { customer?: Customer }
potential_supplier_invoice?: SupplierInvoice
potential_rot_rut_payout?: PotentialRotRutPayout
potential_voucher?: PotentialVoucher
}