From 0069a3f59a01f501d02fc39a208731db29204193 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sun, 6 Sep 2026 21:13:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(reconciliation):=20suggest=20and=20book=20?= =?UTF-8?q?Skatteverkets=20bundled=20ROT/RUT=20payout=20against=20several?= =?UTF-8?q?=20beg=C3=A4ran=20(#2360)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- app/(dashboard)/transactions/page.tsx | 123 +++-- .../__tests__/route.test.ts | 78 +++ .../[id]/match-rot-rut-payout/route.ts | 41 +- components/dashboard/AttGoraSection.tsx | 2 +- components/transactions/InvoicePicker.tsx | 6 +- .../transactions/RotRutPayoutMatchDialog.tsx | 166 ++++-- .../transactions/TransactionInboxCard.tsx | 11 +- components/transactions/transaction-types.ts | 14 +- lib/api/schemas.ts | 20 +- lib/bookkeeping/rot-rut-entries.ts | 133 +++-- lib/errors/structured-errors.ts | 7 + .../rot-rut-payout-set-matching.test.ts | 134 +++++ lib/invoices/__tests__/rot-rut-settle.test.ts | 255 ++++++++- lib/invoices/rot-rut-payout-set-matching.ts | 187 +++++++ lib/invoices/rot-rut-settle.ts | 507 ++++++++++++++---- lib/transactions/__tests__/ingest.test.ts | 47 ++ lib/transactions/ingest.ts | 24 + lib/worklist/__tests__/categories.test.ts | 70 +++ lib/worklist/categories.ts | 101 +++- lib/worklist/types.ts | 7 + messages/en.json | 15 +- messages/sv.json | 15 +- 22 files changed, 1679 insertions(+), 284 deletions(-) create mode 100644 lib/invoices/__tests__/rot-rut-payout-set-matching.test.ts create mode 100644 lib/invoices/rot-rut-payout-set-matching.ts diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 363d6206..51c55ada 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -48,8 +48,10 @@ import type { CategorizeHandler, PotentialVoucher, PotentialRotRutPayout, + PotentialRotRutPayoutRequest, } from '@/components/transactions/transaction-types' import { OPEN_ROT_RUT_PAYOUT_STATUSES } from '@/lib/invoices/rot-rut-payout-matching' +import { matchTransactionsToRotRutPayoutSets } from '@/lib/invoices/rot-rut-payout-set-matching' import { groupExpenseClaimsByPerson, matchTransactionsToExpensePayouts, @@ -241,7 +243,15 @@ async function fetchExpensePayoutMatches( // prod schema cache (see DECISIONS.md 2026-07-06). async function fetchPotentialMatches( supabase: SupabaseClient, + companyId: string | null, rows: { + id: string + amount: number + currency: string | null + description?: string | null + merchant_name?: string | null + is_business: boolean | null + journal_entry_id: string | null potential_invoice_id: string | null potential_supplier_invoice_id: string | null potential_rot_rut_payout_request_id?: string | null @@ -251,13 +261,6 @@ async function fetchPotentialMatches( const potentialInvoiceIds = Array.from( new Set(rows.flatMap((t) => (t.potential_invoice_id ? [t.potential_invoice_id] : []))), ) - const potentialRotRutRequestIds = Array.from( - new Set( - rows.flatMap((t) => - t.potential_rot_rut_payout_request_id ? [t.potential_rot_rut_payout_request_id] : [], - ), - ), - ) const potentialSupplierInvoiceIds = Array.from( new Set(rows.flatMap((t) => (t.potential_supplier_invoice_id ? [t.potential_supplier_invoice_id] : []))), ) @@ -280,7 +283,7 @@ async function fetchPotentialMatches( // an unmatchable candidate must not reach the row or the match dialog, which // would otherwise compare the transaction against a 0 kr remaining balance // and call it a partial payment. - const [invoiceResults, supplierInvoiceResults, voucherResults, rotRutResults] = await Promise.all([ + const [invoiceResults, supplierInvoiceResults, voucherResults, rotRutResult] = await Promise.all([ Promise.all( chunks(potentialInvoiceIds).map((ids) => supabase @@ -314,21 +317,22 @@ async function fetchPotentialMatches( .eq('status', 'posted'), ), ), - // Open ROT/RUT begäran (Skatteverkets utbetalning). Revalidated like the - // invoice hints: a request settled by another row must not reach the - // dialog. Items ride along so the dialog can list the covered invoices. - Promise.all( - chunks(potentialRotRutRequestIds).map((ids) => - supabase + // Open ROT/RUT begäran (Skatteverkets utbetalning): the company's whole + // open pool, not the hinted ids. It serves both the persisted 1:1 hint + // (revalidated: a request settled by another row must not reach the + // dialog) and the read-time covering set for a row Skatteverket paid + // together with other beslut (#2239). Items ride along so the dialog can + // list the covered invoices. A handful of rows per company. + companyId + ? 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))', ) - .in('id', ids) + .eq('company_id', companyId) .in('status', [...OPEN_ROT_RUT_PAYOUT_STATUSES]) - .is('settlement_journal_entry_id', null), - ), - ), + .is('settlement_journal_entry_id', null) + : Promise.resolve({ data: null, error: null }), ]) // Non-fatal: the transaction list still renders without match hints, but @@ -342,12 +346,11 @@ async function fetchPotentialMatches( for (const r of voucherResults) { if (r.error) console.error('[fetchPotentialMatches] journal_entries query failed', r.error) } - for (const r of rotRutResults) { - if (r.error) console.error('[fetchPotentialMatches] rot_rut_payout_requests query failed', r.error) + if (rotRutResult.error) { + console.error('[fetchPotentialMatches] rot_rut_payout_requests query failed', rotRutResult.error) } - const rotRutMap: Record = {} - for (const req of rotRutResults.flatMap((r) => (r.data ?? []) as Array<{ + const rotRutRequests: PotentialRotRutPayoutRequest[] = ((rotRutResult.data ?? []) as Array<{ id: string name: string deduction_type: 'rot' | 'rut' @@ -359,20 +362,31 @@ async function fetchPotentialMatches( requested_amount: number | string invoice?: { invoice_number: string | null } | { invoice_number: string | null }[] | null }> | null - }>)) { - rotRutMap[req.id] = { - 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 } - }), - } + }>).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 } + }), + })) + const rotRutById = new Map(rotRutRequests.map((req) => [req.id, req] as const)) + // Per row: the persisted 1:1 hint when its begäran is still open, else the + // exact covering set over the open pool (several begäran in one transfer). + const rotRutByTransaction = new Map() + for (const row of rows) { + const hinted = row.potential_rot_rut_payout_request_id + ? rotRutById.get(row.potential_rot_rut_payout_request_id) + : undefined + if (hinted) rotRutByTransaction.set(row.id, { requests: [hinted] }) + } + for (const [txId, match] of matchTransactionsToRotRutPayoutSets(rows, rotRutRequests)) { + rotRutByTransaction.set(txId, { requests: match.requests }) } const voucherMap: Record = {} @@ -396,7 +410,7 @@ async function fetchPotentialMatches( invoiceMap: buildInvoiceMap(invoiceResults.flatMap((r) => r.data ?? [])), supplierInvoiceMap: buildSupplierInvoiceMap(supplierInvoiceResults.flatMap((r) => r.data ?? [])), voucherMap, - rotRutMap, + rotRutByTransaction, } } @@ -1171,8 +1185,8 @@ export default function TransactionsPage() { const windowIds = new Set(rows.map((r) => r.id)) const olderPending = (pendingRows ?? []).filter((r) => !windowIds.has(r.id)) const allRows = [...rows, ...olderPending].sort((a, b) => b.date.localeCompare(a.date)) - const [{ invoiceMap, supplierInvoiceMap, voucherMap, rotRutMap }, expensePayouts] = await Promise.all([ - fetchPotentialMatches(supabase, allRows), + const [{ invoiceMap, supplierInvoiceMap, voucherMap, rotRutByTransaction }, expensePayouts] = await Promise.all([ + fetchPotentialMatches(supabase, companyId, allRows), fetchExpensePayoutMatches(supabase, companyId, allRows), ]) const expensePayoutMap = expensePayouts.byTransaction @@ -1189,9 +1203,7 @@ export default function TransactionsPage() { potential_supplier_invoice: t.potential_supplier_invoice_id ? supplierInvoiceMap[t.potential_supplier_invoice_id] : undefined, - potential_rot_rut_payout: t.potential_rot_rut_payout_request_id - ? rotRutMap[t.potential_rot_rut_payout_request_id] - : undefined, + potential_rot_rut_payout: rotRutByTransaction.get(t.id), potential_voucher: t.potential_journal_entry_id ? voucherMap[t.potential_journal_entry_id] : undefined, @@ -1265,8 +1277,8 @@ export default function TransactionsPage() { setPagedThroughDate(txData.length >= PAGE_SIZE ? txData[txData.length - 1].date : null) setHasMore(txData.length >= PAGE_SIZE) - const [{ invoiceMap, supplierInvoiceMap, voucherMap, rotRutMap }, expensePayouts] = await Promise.all([ - fetchPotentialMatches(supabase, txData), + const [{ invoiceMap, supplierInvoiceMap, voucherMap, rotRutByTransaction }, expensePayouts] = await Promise.all([ + fetchPotentialMatches(supabase, companyId, txData), fetchExpensePayoutMatches(supabase, companyId, txData), ]) const expensePayoutMap = expensePayouts.byTransaction @@ -1285,9 +1297,7 @@ export default function TransactionsPage() { potential_supplier_invoice: t.potential_supplier_invoice_id ? supplierInvoiceMap[t.potential_supplier_invoice_id] : undefined, - potential_rot_rut_payout: t.potential_rot_rut_payout_request_id - ? rotRutMap[t.potential_rot_rut_payout_request_id] - : undefined, + potential_rot_rut_payout: rotRutByTransaction.get(t.id), potential_voucher: t.potential_journal_entry_id ? voucherMap[t.potential_journal_entry_id] : undefined, @@ -2393,15 +2403,18 @@ export default function TransactionsPage() { async function handleConfirmRotRutPayoutMatch() { if (!selectedTransaction?.potential_rot_rut_payout) return - const request = selectedTransaction.potential_rot_rut_payout + const { requests } = selectedTransaction.potential_rot_rut_payout + if (requests.length === 0) return setIsConfirmingMatch(true) try { + // One begäran or a bundle Skatteverket paid together: the route books + // one voucher either way and takes the ids as request_ids. const response = await fetch( `/api/transactions/${selectedTransaction.id}/match-rot-rut-payout`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ request_id: request.id }), + body: JSON.stringify({ request_ids: requests.map((request) => request.id) }), }, ) const result = await response.json() @@ -2417,7 +2430,13 @@ export default function TransactionsPage() { toast({ title: t('rot_rut_payout_matched_title'), - description: t('rot_rut_payout_matched_description', { name: request.name }), + description: + requests.length === 1 + ? t('rot_rut_payout_matched_description', { name: requests[0].name }) + : t('rot_rut_payout_matched_set_description', { + count: requests.length, + names: requests.map((request) => request.name).join(', '), + }), }) setRotRutMatchDialogOpen(false) @@ -2694,7 +2713,7 @@ export default function TransactionsPage() { setMatchDialogOpen(true) } - function handleSelectRotRutPayoutFromPicker(request: PotentialRotRutPayout) { + function handleSelectRotRutPayoutFromPicker(request: PotentialRotRutPayoutRequest) { if (!invoicePickerTransaction) return // Same handoff as the invoice pick: close the picker, hang the request on // the row and open the ROT/RUT confirm dialog so the user sees the @@ -2702,7 +2721,7 @@ export default function TransactionsPage() { const tx = invoicePickerTransaction setInvoicePickerOpen(false) setInvoicePickerTransaction(null) - setSelectedTransaction({ ...tx, potential_rot_rut_payout: request }) + setSelectedTransaction({ ...tx, potential_rot_rut_payout: { requests: [request] } }) setRotRutMatchDialogOpen(true) } diff --git a/app/api/transactions/[id]/match-rot-rut-payout/__tests__/route.test.ts b/app/api/transactions/[id]/match-rot-rut-payout/__tests__/route.test.ts index 5be38b07..f623bbec 100644 --- a/app/api/transactions/[id]/match-rot-rut-payout/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-rot-rut-payout/__tests__/route.test.ts @@ -28,8 +28,10 @@ vi.mock('@/lib/init', () => ({ })) const mockSettle = vi.fn() +const mockSettleSet = vi.fn() vi.mock('@/lib/invoices/rot-rut-settle', () => ({ settleRotRutPayoutRequest: (...args: unknown[]) => mockSettle(...args), + settleRotRutPayoutRequestSet: (...args: unknown[]) => mockSettleSet(...args), })) const mockResolveSettlementAccount = vi.fn() @@ -218,4 +220,80 @@ describe('POST /api/transactions/[id]/match-rot-rut-payout', () => { const conflict = await POST(makeReq(), routeParams) expect(conflict.status).toBe(409) }) + + // Several begäran paid in ONE transfer (#2239): request_ids. + const REQUEST_ID_2 = '33333333-3333-4333-8333-333333333333' + + it('returns 400 when neither or both of request_id and request_ids are given', async () => { + let response = await POST(makeReq({}), routeParams) + expect(response.status).toBe(400) + response = await POST( + makeReq({ request_id: REQUEST_ID, request_ids: [REQUEST_ID, REQUEST_ID_2] }), + routeParams, + ) + expect(response.status).toBe(400) + expect(mockSettle).not.toHaveBeenCalled() + expect(mockSettleSet).not.toHaveBeenCalled() + }) + + it('settles a bundle through the set writer with the row amount, date and cash account', async () => { + enqueue({ data: makeTxRow({ amount: 5250 }) }) + mockSettleSet.mockResolvedValue({ + ok: true, + journalEntryId: 'je-set', + amount: 5250, + requests: [ + { id: REQUEST_ID, name: 'ROT 2026-07', status: 'paid' }, + { id: REQUEST_ID_2, name: 'RUT 2026-07', status: 'paid' }, + ], + }) + + const response = await POST(makeReq({ request_ids: [REQUEST_ID, REQUEST_ID_2] }), routeParams) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: string + requests: Array<{ id: string }> + category: string + }>(response) + + expect(status).toBe(200) + expect(body).toMatchObject({ success: true, journal_entry_id: 'je-set', category: 'income_other' }) + expect(body.requests.map((r) => r.id)).toEqual([REQUEST_ID, REQUEST_ID_2]) + expect(mockSettleSet).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', { + requestIds: [REQUEST_ID, REQUEST_ID_2], + paymentDate: '2026-07-10', + amount: 5250, + bankAccount: '1930', + transactionId: TX_ID, + previousJournalEntryId: null, + }) + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('routes a one-element (or duplicated) request_ids through the single writer', async () => { + enqueue({ data: makeTxRow() }) + const response = await POST(makeReq({ request_ids: [REQUEST_ID, REQUEST_ID] }), routeParams) + expect(response.status).toBe(200) + expect(mockSettle).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'company-1', + expect.objectContaining({ requestId: REQUEST_ID }), + ) + expect(mockSettleSet).not.toHaveBeenCalled() + }) + + it('maps the set writer\x27s amount refusal onto the canonical envelope', async () => { + enqueue({ data: makeTxRow({ amount: 5000 }) }) + mockSettleSet.mockResolvedValue({ + ok: false, + kind: 'code', + code: 'ROT_RUT_SETTLE_SET_AMOUNT', + details: { amount: 5000, expected_total: 5250, request_ids: [REQUEST_ID, REQUEST_ID_2] }, + }) + const response = await POST(makeReq({ request_ids: [REQUEST_ID, REQUEST_ID_2] }), routeParams) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('ROT_RUT_SETTLE_SET_AMOUNT') + }) }) diff --git a/app/api/transactions/[id]/match-rot-rut-payout/route.ts b/app/api/transactions/[id]/match-rot-rut-payout/route.ts index 2eb59fa3..eb8e72e4 100644 --- a/app/api/transactions/[id]/match-rot-rut-payout/route.ts +++ b/app/api/transactions/[id]/match-rot-rut-payout/route.ts @@ -4,7 +4,10 @@ import { validateBody } from '@/lib/api/validate' import { MatchRotRutPayoutSchema } from '@/lib/api/schemas' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' -import { settleRotRutPayoutRequest } from '@/lib/invoices/rot-rut-settle' +import { + settleRotRutPayoutRequest, + settleRotRutPayoutRequestSet, +} from '@/lib/invoices/rot-rut-settle' import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry' import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked' import { ensureInitialized } from '@/lib/init' @@ -23,6 +26,11 @@ ensureInitialized() * date and bank account come from the bank row and the row is linked to the * voucher in the same call, so the payout can never be booked twice (once by * settle, once by categorising the bank row). + * + * Skatteverket bundles the beslut it pays that day into one transfer, so the + * body may name several begäran (`request_ids`, #2239): then ONE voucher + * carries one 1513 credit per begäran and the row is linked to it, provided + * the expected payouts sum to the row exactly. */ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( 'transaction.match_rot_rut_payout', @@ -35,9 +43,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( operation: 'transaction.match_rot_rut_payout', }) if (!validation.success) return validation.response - const { request_id: payoutRequestId } = validation.data + const payoutRequestIds = [ + ...new Set(validation.data.request_ids ?? [validation.data.request_id!]), + ] - const txLog = log.child({ transactionId, payoutRequestId }) + const txLog = log.child({ transactionId, payoutRequestIds }) // transaction_voucher_links rides along: a row bulk-booked into a // samlingsverifikat carries journal_entry_id = NULL and must still refuse. @@ -99,16 +109,27 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( txLog, ) - const outcome = await settleRotRutPayoutRequest(supabase, user.id, companyId!, { - requestId: payoutRequestId, + // Shared by both shapes: amount, date and account come from the bank row; + // the link CAS locks on the pointer read above (null for a free row, or + // the stale pointer of a reversed entry the guard let through). + const settleParams = { paymentDate: transaction.date, amount: transaction.amount, bankAccount, transactionId, - // Null for a free row, or the stale pointer of a reversed entry the - // guard above let through: the link CAS locks on exactly this value. previousJournalEntryId: transaction.journal_entry_id, - }) + } + + const outcome = + payoutRequestIds.length === 1 + ? await settleRotRutPayoutRequest(supabase, user.id, companyId!, { + requestId: payoutRequestIds[0], + ...settleParams, + }) + : await settleRotRutPayoutRequestSet(supabase, user.id, companyId!, { + requestIds: payoutRequestIds, + ...settleParams, + }) if (!outcome.ok) { if (outcome.kind === 'code') { @@ -124,13 +145,13 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( userId: user.id, journalEntryId: outcome.journalEntryId, amount: outcome.amount, - fullyPaid: outcome.fullyPaid, + fullyPaid: 'fullyPaid' in outcome ? outcome.fullyPaid : true, }) return NextResponse.json({ success: true, journal_entry_id: outcome.journalEntryId, - request: outcome.request, + ...('request' in outcome ? { request: outcome.request } : { requests: outcome.requests }), category: 'income_other', }) }, diff --git a/components/dashboard/AttGoraSection.tsx b/components/dashboard/AttGoraSection.tsx index 3298cb90..a1b2bee1 100644 --- a/components/dashboard/AttGoraSection.tsx +++ b/components/dashboard/AttGoraSection.tsx @@ -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 } diff --git a/components/transactions/InvoicePicker.tsx b/components/transactions/InvoicePicker.tsx index b46add9b..0e8dffec 100644 --- a/components/transactions/InvoicePicker.tsx +++ b/components/transactions/InvoicePicker.tsx @@ -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([]) - const [rotRutRequests, setRotRutRequests] = useState([]) + const [rotRutRequests, setRotRutRequests] = useState([]) const [isLoading, setIsLoading] = useState(true) const [search, setSearch] = useState('') // Boolean, not the callback: a fresh function identity per parent render diff --git a/components/transactions/RotRutPayoutMatchDialog.tsx b/components/transactions/RotRutPayoutMatchDialog.tsx index c3a7aedf..9fd73dfe 100644 --- a/components/transactions/RotRutPayoutMatchDialog.tsx +++ b/components/transactions/RotRutPayoutMatchDialog.tsx @@ -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 ( @@ -79,7 +94,7 @@ export default function RotRutPayoutMatchDialog({ - {transaction && request && ( + {transaction && requests.length > 0 && (

{t('transaction_label')}

@@ -92,32 +107,50 @@ export default function RotRutPayoutMatchDialog({
-
-

{t('request_label')}

-

{t('request_name', { type: typeLabel, name: request.name })}

-
- - {t('requested_total', { amount: formatCurrency(requestedTotal, 'SEK') })} - - {request.decided_total != null && ( - - {t('decided_total', { amount: formatCurrency(Number(request.decided_total), 'SEK') })} - - )} -
- {request.invoices.length > 0 && ( -
-

{t('invoices_title')}

-
    - {request.invoices.map((inv, i) => ( -
  • - {t('invoice_row', { number: inv.invoice_number ?? '' })} - - {formatCurrency(Number(inv.requested_amount), 'SEK')} - -
  • - ))} -
+
+

+ {isSet ? t('requests_label', { count: requests.length }) : t('request_label')} +

+ {requests.map((request) => ( +
+

+ {t('request_name', { type: typeLabel(request.deduction_type), name: request.name })} +

+
+ + {t('requested_total', { + amount: formatCurrency(roundOre(Number(request.requested_total)), 'SEK'), + })} + + {request.decided_total != null && ( + + {t('decided_total', { + amount: formatCurrency(Number(request.decided_total), 'SEK'), + })} + + )} +
+ {request.invoices.length > 0 && ( +
+

{t('invoices_title')}

+
    + {request.invoices.map((inv, i) => ( +
  • + {t('invoice_row', { number: inv.invoice_number ?? '' })} + + {formatCurrency(Number(inv.requested_amount), 'SEK')} + +
  • + ))} +
+
+ )} +
+ ))} + {isSet && ( +
+ {t('requests_total_label')} + {formatCurrency(expected, 'SEK')}
)}
@@ -127,11 +160,11 @@ export default function RotRutPayoutMatchDialog({

- {t(targetState === 'settled' ? 'target_settled_title' : 'target_not_open_title')} + {t(blockedState === 'settled' ? 'target_settled_title' : 'target_not_open_title')}

{t( - targetState === 'settled' + blockedState === 'settled' ? 'target_settled_description' : 'target_not_open_description', )} @@ -141,14 +174,17 @@ export default function RotRutPayoutMatchDialog({ ) : amountsMatch ? (

-

{t('amounts_match')}

+

+ {isSet ? t('amounts_match_set') : t('amounts_match')} +

) : (
-

{t('amounts_differ')}

+

{isSet ? t('amounts_differ_set') : t('amounts_differ')}

{t('amount_diff', { amount: formatCurrency(diff, currency) })}

+ {setBlocked &&

{t('set_exact_required')}

} {overBlocked &&

{t('over_payout_blocked')}

} {partialBlocked &&

{t('partial_requires_beslut')}

} {isPartial && !partialBlocked && ( @@ -169,13 +205,27 @@ export default function RotRutPayoutMatchDialog({ {formatCurrency(txAmount, currency)}
-
- - {t('booking_credit')}{' '} - {t('booking_receivable_line')} - - {formatCurrency(txAmount, currency)} -
+ {isSet ? ( + requests.map((request) => ( +
+ + {t('booking_credit')}{' '} + {t('booking_receivable_line_named', { name: request.name })} + + + {formatCurrency(expectedRotRutPayoutAmount(request), currency)} + +
+ )) + ) : ( +
+ + {t('booking_credit')}{' '} + {t('booking_receivable_line')} + + {formatCurrency(txAmount, currency)} +
+ )}
)} @@ -185,7 +235,9 @@ export default function RotRutPayoutMatchDialog({

{t('on_confirm_title')}

  • • {t('on_confirm_link')}
  • -
  • • {t('on_confirm_request')}
  • +
  • + • {isSet ? t('on_confirm_requests', { count: requests.length }) : t('on_confirm_request')} +
  • • {t('on_confirm_voucher')}
@@ -200,7 +252,13 @@ export default function RotRutPayoutMatchDialog({