feat(reconciliation): suggest and book Skatteverkets bundled ROT/RUT payout against several begäran (#2360)
* feat(reconciliation): suggest and book Skatteverkets bundled ROT/RUT payout against several begäran Skatteverket decides per begäran but pays everything it decided that day in one transfer, so the bank row often equals no single open begäran and the 1:1 matcher from #2271 stayed silent; the settle service then refused the amount and the dialog told the user to split the transaction by hand. The candidate is now the exact covering set of 1..4 open begäran whose expected payouts sum to the row (lib/invoices/rot-rut-payout-set-matching.ts, over the existing findExactCoveringSet, ambiguity-refusing). It is computed at read time from the open pool (inbox page, worklist, ingest), no hint column. Confirming books ONE voucher (debit 19xx, one 1513 credit per begäran) through the same writer, marks every begäran paid and links the row once; a bundle is always booked at exactly the decided sums. - match-rot-rut-payout accepts request_ids (1..10) beside request_id - settleRotRutPayoutRequestSet shares the single path's tail - createRotRutPayoutSetEntry; createRotRutPayoutEntry delegates (N=1 unchanged) - inbox pill, RotRutPayoutMatchDialog, Att göra and ingest handle the set - new error code ROT_RUT_SETTLE_SET_AMOUNT; sv/en strings for the set Closes #2239 Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(invoices): PR #2360 review: partially decided begäran stays partially_paid in a bundle The bundle path booked every leg as fully paid and mirrored requested_amount onto every begäran's items, while the single path completes a begäran only when the leg covers requested_total and otherwise leaves it partially_paid for manual handling. A begäran Skatteverket decided at less than requested is a legitimate bundle member (its leg is the beslut, the exact-sum rule is unchanged), so the set path now computes fullyPaid per leg exactly like the single path, passes it to the shared attachSettlementVoucher, and mirrors only the fully paid legs; sibling hints are still cleared for every settled begäran, which carries a voucher and is no longer matchable either way. Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- 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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
4bc007cdeb
commit
0069a3f59a
@@ -173,7 +173,7 @@ export default function AttGoraSection({
|
||||
match.kind === 'invoice'
|
||||
? { invoice_id: match.candidate_id }
|
||||
: match.kind === 'rot_rut_payout'
|
||||
? { request_id: match.candidate_id }
|
||||
? { request_ids: match.request_ids ?? [match.candidate_id] }
|
||||
: match.kind === 'expense_payout'
|
||||
? { claim_ids: match.claim_ids ?? [] }
|
||||
: { supplier_invoice_id: match.candidate_id }
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 { PotentialRotRutPayout, TransactionWithInvoice } from './transaction-types'
|
||||
import type { PotentialRotRutPayoutRequest, TransactionWithInvoice } from './transaction-types'
|
||||
import {
|
||||
DOMESTIC_CURRENCY,
|
||||
normalizeCurrency,
|
||||
@@ -27,7 +27,7 @@ interface InvoicePickerProps {
|
||||
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
|
||||
onSelectRotRutPayout?: (request: PotentialRotRutPayoutRequest) => void
|
||||
}
|
||||
|
||||
type RotRutRequestRow = {
|
||||
@@ -49,7 +49,7 @@ export default function InvoicePicker({ transaction, onSelect, onSelectRotRutPay
|
||||
const { company } = useCompany()
|
||||
const supabase = useMemo(() => createClient(), [])
|
||||
const [invoices, setInvoices] = useState<OpenInvoice[]>([])
|
||||
const [rotRutRequests, setRotRutRequests] = useState<PotentialRotRutPayout[]>([])
|
||||
const [rotRutRequests, setRotRutRequests] = useState<PotentialRotRutPayoutRequest[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
// Boolean, not the callback: a fresh function identity per parent render
|
||||
|
||||
@@ -29,13 +29,16 @@ interface RotRutPayoutMatchDialogProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Confirm dialog for matching an income bank row to one or several open
|
||||
* ROT/RUT begäran: Skatteverkets utbetalning clears the 1513 receivable
|
||||
* (debit the row's cash account, one 1513 credit per begäran) 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.
|
||||
* no editable lines. The legs are known up front, so everything the user
|
||||
* needs to approve is on screen. A bundle (several begäran paid in one
|
||||
* transfer) is booked at exactly the decided sums: partial and over
|
||||
* variants exist only for a single begäran.
|
||||
*/
|
||||
export default function RotRutPayoutMatchDialog({
|
||||
open,
|
||||
@@ -45,29 +48,41 @@ export default function RotRutPayoutMatchDialog({
|
||||
onConfirm,
|
||||
}: RotRutPayoutMatchDialogProps) {
|
||||
const t = useTranslations('tx_rot_rut_match')
|
||||
const request = transaction?.potential_rot_rut_payout ?? null
|
||||
const requests = transaction?.potential_rot_rut_payout?.requests ?? []
|
||||
const isSet = requests.length > 1
|
||||
const single = requests.length === 1 ? requests[0] : null
|
||||
|
||||
const targetState = getRotRutPayoutMatchTargetState(request)
|
||||
const targetBlocked = targetState !== 'matchable'
|
||||
// Every begäran must still be open and unsettled; the first blocked one
|
||||
// explains the refusal.
|
||||
const blockedState =
|
||||
requests.map((request) => getRotRutPayoutMatchTargetState(request)).find((s) => s !== 'matchable') ??
|
||||
null
|
||||
const targetBlocked = requests.length === 0 || blockedState !== null
|
||||
|
||||
const txAmount = transaction ? roundOre(transaction.amount) : 0
|
||||
const expected = request ? expectedRotRutPayoutAmount(request) : 0
|
||||
const requestedTotal = request ? roundOre(Number(request.requested_total)) : 0
|
||||
const expected = roundOre(
|
||||
requests.reduce((sum, request) => sum + expectedRotRutPayoutAmount(request), 0),
|
||||
)
|
||||
const requestedTotal = roundOre(
|
||||
requests.reduce((sum, request) => sum + 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
|
||||
const isPartial = single ? txAmount < requestedTotal - 0.005 : false
|
||||
const partialBlocked = isPartial && single?.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
|
||||
const overBlocked = single ? txAmount > expected + 0.005 : false
|
||||
// A bundle only ever books the exact sum of its begäran.
|
||||
const setBlocked = isSet && !amountsMatch
|
||||
// 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'
|
||||
const typeLabel = (deductionType: 'rot' | 'rut') => (deductionType === 'rut' ? 'RUT' : 'ROT')
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -79,7 +94,7 @@ export default function RotRutPayoutMatchDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{transaction && request && (
|
||||
{transaction && requests.length > 0 && (
|
||||
<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>
|
||||
@@ -92,32 +107,50 @@ export default function RotRutPayoutMatchDialog({
|
||||
</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 className="rounded-lg border p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{isSet ? t('requests_label', { count: requests.length }) : t('request_label')}
|
||||
</p>
|
||||
{requests.map((request) => (
|
||||
<div key={request.id} className="space-y-2">
|
||||
<p className="font-medium">
|
||||
{t('request_name', { type: typeLabel(request.deduction_type), name: request.name })}
|
||||
</p>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t('requested_total', {
|
||||
amount: formatCurrency(roundOre(Number(request.requested_total)), '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>
|
||||
))}
|
||||
{isSet && (
|
||||
<div className="flex justify-between border-t pt-2 text-sm font-medium">
|
||||
<span>{t('requests_total_label')}</span>
|
||||
<span className="tabular-nums">{formatCurrency(expected, 'SEK')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -127,11 +160,11 @@ export default function RotRutPayoutMatchDialog({
|
||||
<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')}
|
||||
{t(blockedState === 'settled' ? 'target_settled_title' : 'target_not_open_title')}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
targetState === 'settled'
|
||||
blockedState === 'settled'
|
||||
? 'target_settled_description'
|
||||
: 'target_not_open_description',
|
||||
)}
|
||||
@@ -141,14 +174,17 @@ export default function RotRutPayoutMatchDialog({
|
||||
) : 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>
|
||||
<p className="text-sm font-medium">
|
||||
{isSet ? t('amounts_match_set') : 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 className="font-medium">{isSet ? t('amounts_differ_set') : t('amounts_differ')}</p>
|
||||
<p>{t('amount_diff', { amount: formatCurrency(diff, currency) })}</p>
|
||||
{setBlocked && <p className="mt-1">{t('set_exact_required')}</p>}
|
||||
{overBlocked && <p className="mt-1">{t('over_payout_blocked')}</p>}
|
||||
{partialBlocked && <p className="mt-1">{t('partial_requires_beslut')}</p>}
|
||||
{isPartial && !partialBlocked && (
|
||||
@@ -169,13 +205,27 @@ export default function RotRutPayoutMatchDialog({
|
||||
</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>
|
||||
{isSet ? (
|
||||
requests.map((request) => (
|
||||
<div key={request.id} className="flex justify-between">
|
||||
<span>
|
||||
<span className="text-muted-foreground">{t('booking_credit')}</span>{' '}
|
||||
{t('booking_receivable_line_named', { name: request.name })}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{formatCurrency(expectedRotRutPayoutAmount(request), 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>
|
||||
)}
|
||||
@@ -185,7 +235,9 @@ export default function RotRutPayoutMatchDialog({
|
||||
<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>
|
||||
• {isSet ? t('on_confirm_requests', { count: requests.length }) : t('on_confirm_request')}
|
||||
</li>
|
||||
<li>• {t('on_confirm_voucher')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -200,7 +252,13 @@ export default function RotRutPayoutMatchDialog({
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={
|
||||
isConfirming || !request || targetBlocked || partialBlocked || overBlocked || currencyBlocked
|
||||
isConfirming ||
|
||||
requests.length === 0 ||
|
||||
targetBlocked ||
|
||||
partialBlocked ||
|
||||
overBlocked ||
|
||||
setBlocked ||
|
||||
currencyBlocked
|
||||
}
|
||||
>
|
||||
{isConfirming ? t('confirming') : t('confirm')}
|
||||
|
||||
@@ -186,9 +186,10 @@ 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
|
||||
// Skatteverkets ROT/RUT-utbetalning for one or several open begäran: same
|
||||
// 1-click shortcut as an invoice match, confirmed in its own dialog.
|
||||
const rotRutRequests = transaction.potential_rot_rut_payout?.requests ?? []
|
||||
const hasRotRutPayoutMatch = rotRutRequests.length > 0 && !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
|
||||
@@ -214,7 +215,9 @@ export default function TransactionInboxCard({
|
||||
number: transaction.potential_supplier_invoice!.supplier_invoice_number ?? '',
|
||||
})
|
||||
: hasRotRutPayoutMatch
|
||||
? t('match_rot_rut_payout_btn', { name: transaction.potential_rot_rut_payout!.name })
|
||||
? rotRutRequests.length === 1
|
||||
? t('match_rot_rut_payout_btn', { name: rotRutRequests[0].name })
|
||||
: t('match_rot_rut_payout_set_btn', { count: rotRutRequests.length })
|
||||
: hasExpensePayoutMatch
|
||||
? t('match_expense_payout_btn', { name: transaction.potential_expense_payout!.claimant_name })
|
||||
: null
|
||||
|
||||
@@ -2,12 +2,20 @@ import type { Transaction, TransactionCategory, Invoice, Customer, SupplierInvoi
|
||||
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). */
|
||||
export interface PotentialRotRutPayout extends RotRutPayoutRequestCandidate {
|
||||
/** An open ROT/RUT begäran offered to an income row, with the invoices it
|
||||
* covers (so the user sees which fakturor the payout settles). */
|
||||
export interface PotentialRotRutPayoutRequest extends RotRutPayoutRequestCandidate {
|
||||
invoices: Array<{ invoice_number: string | null; requested_amount: number | string }>
|
||||
}
|
||||
|
||||
/** The ROT/RUT match suggestion hung onto an income row: the begäran
|
||||
* Skatteverkets utbetalning settles. One for a persisted 1:1 hint or a
|
||||
* manual pick; several when the transfer bundles the beslut paid that day
|
||||
* (computed at read time, lib/invoices/rot-rut-payout-set-matching.ts). */
|
||||
export interface PotentialRotRutPayout {
|
||||
requests: PotentialRotRutPayoutRequest[]
|
||||
}
|
||||
|
||||
/** Revalidated journal-entry match suggestion hung onto a row (mirrors
|
||||
* potential_invoice): present only when the suggested entry is still posted. */
|
||||
export interface PotentialVoucher {
|
||||
|
||||
Reference in New Issue
Block a user