From 50b6299699defb91a5f19b62f4af64fa4065652c Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:33:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(rot-rut):=20match=20Skatteverket's=20payou?= =?UTF-8?q?t=20against=20the=20beg=C3=A4ran=20from=20the=20bank=20row=20(#?= =?UTF-8?q?2271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5 Signed-off-by: Emil * 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 Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5 Signed-off-by: Emil * 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 Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5 Signed-off-by: Emil --------- Signed-off-by: Emil Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 1 + app/(dashboard)/transactions/page.tsx | 171 ++++++++- .../payout-requests/[id]/settle/route.ts | 145 ++------ .../__tests__/route.test.ts | 221 ++++++++++++ .../[id]/match-rot-rut-payout/route.ts | 138 ++++++++ .../__tests__/route.test.ts | 7 + .../batch-match-invoices/route.ts | 29 +- components/dashboard/AttGoraSection.tsx | 12 +- components/transactions/InvoicePicker.tsx | 123 ++++++- components/transactions/QuickReviewDialog.tsx | 1 + .../transactions/RotRutPayoutMatchDialog.tsx | 212 +++++++++++ .../transactions/TransactionInboxCard.tsx | 9 +- components/transactions/transaction-types.ts | 8 + lib/api/schemas.ts | 9 + lib/errors/structured-errors.ts | 36 ++ .../__tests__/rot-rut-payout-matching.test.ts | 140 ++++++++ lib/invoices/__tests__/rot-rut-settle.test.ts | 321 +++++++++++++++++ .../clear-settled-invoice-suggestions.ts | 10 +- lib/invoices/rot-rut-payout-candidates.ts | 31 ++ lib/invoices/rot-rut-payout-matching.ts | 137 +++++++ lib/invoices/rot-rut-settle.ts | 335 ++++++++++++++++++ lib/reconciliation/bank-reconciliation.ts | 9 +- lib/transactions/__tests__/ingest.test.ts | 55 +++ lib/transactions/ingest.ts | 50 +++ lib/transactions/link-journal-entry.ts | 5 +- lib/worklist/__tests__/categories.test.ts | 39 ++ lib/worklist/categories.ts | 52 ++- lib/worklist/types.ts | 8 +- messages/en.json | 41 +++ messages/sv.json | 41 +++ ...20000_rot_rut_payout_transaction_match.sql | 17 + ...04021000_rot_rut_payout_voucher_unique.sql | 20 ++ .../rot-rut-payout-voucher-unique.pg.test.ts | 59 +++ types/index.ts | 5 + 34 files changed, 2350 insertions(+), 147 deletions(-) create mode 100644 app/api/transactions/[id]/match-rot-rut-payout/__tests__/route.test.ts create mode 100644 app/api/transactions/[id]/match-rot-rut-payout/route.ts create mode 100644 components/transactions/RotRutPayoutMatchDialog.tsx create mode 100644 lib/invoices/__tests__/rot-rut-payout-matching.test.ts create mode 100644 lib/invoices/__tests__/rot-rut-settle.test.ts create mode 100644 lib/invoices/rot-rut-payout-candidates.ts create mode 100644 lib/invoices/rot-rut-payout-matching.ts create mode 100644 lib/invoices/rot-rut-settle.ts create mode 100644 supabase/migrations/20260904020000_rot_rut_payout_transaction_match.sql create mode 100644 supabase/migrations/20260904021000_rot_rut_payout_voucher_unique.sql create mode 100644 tests/pg/rot-rut-payout-voucher-unique.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 0bd6fea3..78a3ed22 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1564,4 +1564,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-03] Decision lines for PRs #2242 (#2237), #2246 (#2203) and #2245 (#2214) are carried in this PR's commit rather than their own: DECISIONS.md is append-only, so every squash-merge flips every other open PR to CONFLICTING and costs a full CI round each; consolidating the lines into the last PR of the batch turns four rounds into one. [2026-09-03] The country backfill ships as 20260903173000, not 20260903170000: the first version failed on prod at the customers UPDATE because rows of a migration-reset source company are immutable by trigger (block_migration_reset_source_mutation), and the whole file rolled back. The new file skips those companies in every UPDATE (their legacy text is still normalised at read time) and the old file is removed rather than edited, since prod never recorded it; staging was re-tracked by hand under the new version. [2026-09-03] Per-invoice payee migrations re-issued as 20260904010000 and 20260904011000 (were 20260903150000 / 20260903193000, merged in #2233 but never applied): the backfill's INSERT into invoice_payee_defaults fired the mirror into company_settings for a company that is a migration-reset source, whose rows are immutable by trigger, so the whole migration rolled back on prod and every later migration queued behind it. Same pattern as #2249: skip company_migration_resets sources in the backfill and re-issue under a fresh version rather than edit the failed file in place, so any environment that did apply the old version (staging, by hand) is reconciled by renaming its schema_migrations row instead of diverging silently. +[2026-09-04] ROT/RUT payout matching models the begäran (rot_rut_payout_requests), not the invoice, as the bank-row match candidate: Skatteverket pays one lump sum per begäran covering several invoices, remaining_amount is net of the deduction so a paid ROT/RUT invoice can never match, and 1513 clears per request. Confirm reuses the settle service with the transaction linked in the same call; its own dialog (RotRutPayoutMatchDialog) rather than a third branch in InvoiceMatchDialog, which carries FX/preview/edit logic this two-leg entry never needs. [2026-09-04] Parties name extraction is rule-based first (legal-form and country anchors in lib/parties/name-extract.ts), no LLM in the batch: every candidate is a substring of the voucher text, testable and free; an AI read for the leftovers (bank memos with no anchor) waits for the founder's call on automatic vs on-click. The picker makes no SCB call when the best reading is a foreign company: the register holds Swedish legal persons only, so a search there can only mislead. diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 4fac088d..22281dbe 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -47,7 +47,9 @@ import type { SourceFilter, CategorizeHandler, PotentialVoucher, + PotentialRotRutPayout, } from '@/components/transactions/transaction-types' +import { OPEN_ROT_RUT_PAYOUT_STATUSES } from '@/lib/invoices/rot-rut-payout-matching' import { SuggestionReviewList } from '@/components/transactions/SuggestionReviewList' import { isSourceFilter, @@ -98,6 +100,7 @@ function InlineDialogContentLoading() { const TransactionForm = dynamic(() => import('@/components/transactions/TransactionForm'), { loading: InlineDialogContentLoading }) const BatchCategorySelector = dynamic(() => import('@/components/transactions/BatchCategorySelector'), { loading: DialogLoadingSkeleton }) const InvoiceMatchDialog = dynamic(() => import('@/components/transactions/InvoiceMatchDialog'), { loading: DialogLoadingSkeleton }) +const RotRutPayoutMatchDialog = dynamic(() => import('@/components/transactions/RotRutPayoutMatchDialog'), { loading: DialogLoadingSkeleton }) const MatchVoucherDialog = dynamic( () => import('@/components/transactions/MatchVoucherDialog').then((module) => module.MatchVoucherDialog), { loading: DialogLoadingSkeleton }, @@ -199,12 +202,20 @@ async function fetchPotentialMatches( rows: { potential_invoice_id: string | null potential_supplier_invoice_id: string | null + potential_rot_rut_payout_request_id?: string | null potential_journal_entry_id?: string | null }[], ) { 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] : []))), ) @@ -227,7 +238,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] = await Promise.all([ + const [invoiceResults, supplierInvoiceResults, voucherResults, rotRutResults] = await Promise.all([ Promise.all( chunks(potentialInvoiceIds).map((ids) => supabase @@ -261,6 +272,21 @@ 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 + .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) + .in('status', [...OPEN_ROT_RUT_PAYOUT_STATUSES]) + .is('settlement_journal_entry_id', null), + ), + ), ]) // Non-fatal: the transaction list still renders without match hints, but @@ -274,6 +300,38 @@ 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) + } + + const rotRutMap: Record = {} + for (const req of rotRutResults.flatMap((r) => (r.data ?? []) as Array<{ + 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 + }>)) { + 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 } + }), + } + } const voucherMap: Record = {} for (const je of voucherResults.flatMap((r) => (r.data ?? []) as Array<{ @@ -296,6 +354,7 @@ async function fetchPotentialMatches( invoiceMap: buildInvoiceMap(invoiceResults.flatMap((r) => r.data ?? [])), supplierInvoiceMap: buildSupplierInvoiceMap(supplierInvoiceResults.flatMap((r) => r.data ?? [])), voucherMap, + rotRutMap, } } @@ -339,6 +398,9 @@ export default function TransactionsPage() { const [matchDialogOpen, setMatchDialogOpen] = useState(false) const [selectedTransaction, setSelectedTransaction] = useState(null) const [isConfirmingMatch, setIsConfirmingMatch] = useState(false) + // ROT/RUT payout confirm (Skatteverkets utbetalning for an open begäran): + // its own dialog, same selectedTransaction / isConfirmingMatch plumbing. + const [rotRutMatchDialogOpen, setRotRutMatchDialogOpen] = useState(false) // Booking dialog (journal entry form) const [bookingDialogOpen, setBookingDialogOpen] = useState(false) @@ -612,8 +674,8 @@ export default function TransactionsPage() { // animation still finishes instead of being cut to a jump. .filter((t) => (t.is_business === null && !t.is_ignored) || exitingIds.has(t.id)) .sort((a, b) => { - const aHasMatch = a.potential_invoice || a.potential_supplier_invoice ? 1 : 0 - const bHasMatch = b.potential_invoice || b.potential_supplier_invoice ? 1 : 0 + const aHasMatch = a.potential_invoice || a.potential_supplier_invoice || a.potential_rot_rut_payout ? 1 : 0 + const bHasMatch = b.potential_invoice || b.potential_supplier_invoice || b.potential_rot_rut_payout ? 1 : 0 if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch return b.date.localeCompare(a.date) }), @@ -1060,7 +1122,7 @@ 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 } = await fetchPotentialMatches(supabase, allRows) + const { invoiceMap, supplierInvoiceMap, voucherMap, rotRutMap } = await fetchPotentialMatches(supabase, allRows) // Re-check after the second await: a scope change during the match // enrichment must also discard this response. @@ -1072,6 +1134,9 @@ 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_voucher: t.potential_journal_entry_id ? voucherMap[t.potential_journal_entry_id] : undefined, @@ -1145,7 +1210,7 @@ export default function TransactionsPage() { setPagedThroughDate(txData.length >= PAGE_SIZE ? txData[txData.length - 1].date : null) setHasMore(txData.length >= PAGE_SIZE) - const { invoiceMap, supplierInvoiceMap, voucherMap } = await fetchPotentialMatches(supabase, txData) + const { invoiceMap, supplierInvoiceMap, voucherMap, rotRutMap } = await fetchPotentialMatches(supabase, txData) // Same staleness rule after the enrichment await: the offsets above were // written under this generation, but a newer fetch has already reset them. @@ -1160,6 +1225,9 @@ 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_voucher: t.potential_journal_entry_id ? voucherMap[t.potential_journal_entry_id] : undefined, @@ -2163,6 +2231,66 @@ export default function TransactionsPage() { } } + async function handleConfirmRotRutPayoutMatch() { + if (!selectedTransaction?.potential_rot_rut_payout) return + const request = selectedTransaction.potential_rot_rut_payout + setIsConfirmingMatch(true) + try { + 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 }), + }, + ) + const result = await response.json() + if (!response.ok) { + toast({ + title: t('rot_rut_payout_match_failed_title'), + description: getErrorMessage(result, { context: 'transaction' }), + variant: 'destructive', + }) + setIsConfirmingMatch(false) + return + } + + toast({ + title: t('rot_rut_payout_matched_title'), + description: t('rot_rut_payout_matched_description', { name: request.name }), + }) + setRotRutMatchDialogOpen(false) + + setExitingIds((prev) => new Set(prev).add(selectedTransaction.id)) + setTimeout(() => { + setTransactions((prev) => + prev.map((tx) => + tx.id === selectedTransaction.id + ? { + ...tx, + potential_rot_rut_payout_request_id: null, + potential_rot_rut_payout: undefined, + is_business: true, + category: 'income_other' as TransactionCategory, + journal_entry_id: result.journal_entry_id, + } + : tx, + ), + ) + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(selectedTransaction.id) + return next + }) + setSelectedTransaction(null) + setIsConfirmingMatch(false) + }, 350) + } catch { + toast({ title: t('match_failed_title'), description: t('match_failed_transaction'), variant: 'destructive' }) + setIsConfirmingMatch(false) + } + } + async function handleLinkToExistingVoucher(journalEntryId: string) { if (!selectedTransaction) return const invoiceId = selectedTransaction.potential_invoice?.id ?? null @@ -2406,6 +2534,18 @@ export default function TransactionsPage() { setMatchDialogOpen(true) } + function handleSelectRotRutPayoutFromPicker(request: PotentialRotRutPayout) { + 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 + // 19xx / 1513 entry before it is booked. + const tx = invoicePickerTransaction + setInvoicePickerOpen(false) + setInvoicePickerTransaction(null) + setSelectedTransaction({ ...tx, potential_rot_rut_payout: request }) + setRotRutMatchDialogOpen(true) + } + function handleSelectSupplierInvoiceFromPicker(invoice: SupplierInvoice & { supplier?: Supplier }) { if (!supplierInvoicePickerTransaction) return // Route through the confirm dialog so the supplier-side JE preview @@ -3423,6 +3563,16 @@ export default function TransactionsPage() { function openMatchDialog(transaction: TransactionWithInvoice) { setSelectedTransaction(transaction) + // An invoice hint wins when both are present (mirrors the inbox card's + // label precedence); the ROT/RUT payout has its own confirm dialog. + if ( + !transaction.potential_invoice && + !transaction.potential_supplier_invoice && + transaction.potential_rot_rut_payout + ) { + setRotRutMatchDialogOpen(true) + return + } setMatchDialogOpen(true) } @@ -4090,6 +4240,16 @@ export default function TransactionsPage() { /> )} + {rotRutMatchDialogOpen && ( + + )} + {matchVoucherTx && ( )} diff --git a/app/api/rot-rut/payout-requests/[id]/settle/route.ts b/app/api/rot-rut/payout-requests/[id]/settle/route.ts index 18a47eab..d7dd7751 100644 --- a/app/api/rot-rut/payout-requests/[id]/settle/route.ts +++ b/app/api/rot-rut/payout-requests/[id]/settle/route.ts @@ -3,7 +3,7 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { RotRutSettleSchema } from '@/lib/api/schemas' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' -import { createRotRutPayoutEntry } from '@/lib/bookkeeping/rot-rut-entries' +import { settleRotRutPayoutRequest } from '@/lib/invoices/rot-rut-settle' /** * POST /api/rot-rut/payout-requests/[id]/settle @@ -18,6 +18,11 @@ import { createRotRutPayoutEntry } from '@/lib/bookkeeping/rot-rut-entries' * amount defaults to decided_total, falling back to requested_total. If the * amount equals requested_total the request completes as 'paid'; anything * lower records 'partially_paid' with decided_total = amount. + * + * Headless variant: no bank transaction is linked. The transactions inbox + * settles the same request WITH the bank row through + * POST /api/transactions/[id]/match-rot-rut-payout (shared service in + * lib/invoices/rot-rut-settle.ts). */ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( 'rot_rut.requests.settle', @@ -29,134 +34,36 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( if (!validation.success) return validation.response const input = validation.data - const { data: payoutRequest, error: fetchError } = await supabase - .from('rot_rut_payout_requests') - .select('*') - .eq('company_id', companyId!) - .eq('id', id) - .maybeSingle() + const outcome = await settleRotRutPayoutRequest(supabase, user.id, companyId!, { + requestId: id, + paymentDate: input.payment_date, + amount: input.amount, + bankAccount: input.bank_account, + }) - if (fetchError) { - log.error('failed to fetch rot/rut payout request', fetchError) - return errorResponse(fetchError, log, { requestId }) - } - if (!payoutRequest) { - return errorResponseFromCode('ROT_RUT_REQUEST_NOT_FOUND', log, { requestId }) - } - - const settleable = - !payoutRequest.settlement_journal_entry_id && - !['cancelled', 'rejected'].includes(payoutRequest.status) - if (!settleable) { - return errorResponseFromCode('ROT_RUT_SETTLE_INVALID_STATE', log, { - requestId, - details: { - status: payoutRequest.status, - already_settled: !!payoutRequest.settlement_journal_entry_id, - }, - }) - } - - const amount = - input.amount ?? Number(payoutRequest.decided_total ?? payoutRequest.requested_total) - - // A partial settlement must follow a recorded beslut: without this guard a - // settle with amount < requested_total on an undecided request would flip - // it to partially_paid while bypassing the PATCH lifecycle rule that - // partially_paid requires decided_total: the beslut would never be - // recorded and later PATCH calls would be blocked by ALLOWED_TRANSITIONS. - if (amount < Number(payoutRequest.requested_total) && payoutRequest.decided_total == null) { - return errorResponseFromCode('ROT_RUT_SETTLE_INVALID_STATE', log, { - requestId, - details: { - status: payoutRequest.status, - reason: - 'Delutbetalning kräver att Skatteverkets beslut registreras först (decided_total via PATCH).', - }, - }) - } - - // The voucher is the accounting record: engine failure must block. - let journalEntryId: string - try { - const entry = await createRotRutPayoutEntry(supabase, companyId!, user.id, { - requestId: payoutRequest.id, - requestName: payoutRequest.name, - deductionType: payoutRequest.deduction_type, - paymentDate: input.payment_date, - amount, - bankAccount: input.bank_account, - }) - journalEntryId = entry.id - } catch (engineError) { - log.error('failed to book rot/rut payout entry', engineError as Error) - return errorResponse(engineError, log, { requestId }) - } - - const fullyPaid = amount >= Number(payoutRequest.requested_total) - const update: Record = { - settlement_journal_entry_id: journalEntryId, - status: fullyPaid ? 'paid' : 'partially_paid', - decided_total: payoutRequest.decided_total ?? amount, - } - if (!payoutRequest.decided_at) { - update.decided_at = new Date().toISOString() - } - - const { data: updated, error: updateError } = await supabase - .from('rot_rut_payout_requests') - .update(update) - .eq('company_id', companyId!) - .eq('id', id) - .select( - 'id, name, deduction_type, status, requested_total, decided_total, decided_at, settlement_journal_entry_id', - ) - .single() - - if (updateError) { - // The voucher exists (immutable per BFL) but the request row didn't - // absorb the link: surface loudly, do NOT try to unbook. - log.error('rot/rut payout entry booked but request update failed', updateError, { - journalEntryId, - payoutRequestId: id, - }) - return errorResponse(updateError, log, { requestId }) - } - - if (fullyPaid) { - const { data: items, error: itemsFetchError } = await supabase - .from('rot_rut_payout_request_items') - .select('id, requested_amount') - .eq('request_id', id) - if (itemsFetchError) { - log.warn('failed to fetch items for decided_amount mirror', { - payoutRequestId: id, - message: itemsFetchError.message, - }) + if (!outcome.ok) { + if (outcome.kind === 'code') { + return errorResponseFromCode(outcome.code, log, { requestId, details: outcome.details }) } - for (const item of items ?? []) { - const { error: mirrorError } = await supabase - .from('rot_rut_payout_request_items') - .update({ decided_amount: item.requested_amount }) - .eq('id', item.id) - if (mirrorError) { - log.warn('failed to mirror decided_amount onto item', { - itemId: item.id, - message: mirrorError.message, - }) - } + if (outcome.stage === 'fetch') { + log.error('failed to fetch rot/rut payout request', outcome.error as Error) + } else if (outcome.stage === 'book') { + log.error('failed to book rot/rut payout entry', outcome.error as Error) } + return errorResponse(outcome.error, log, { requestId }) } log.info('rot/rut payout settled', { userId: user.id, payoutRequestId: id, - journalEntryId, - amount, - fullyPaid, + journalEntryId: outcome.journalEntryId, + amount: outcome.amount, + fullyPaid: outcome.fullyPaid, }) - return NextResponse.json({ data: { request: updated, journal_entry_id: journalEntryId } }) + return NextResponse.json({ + data: { request: outcome.request, journal_entry_id: outcome.journalEntryId }, + }) }, { requireWrite: 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 new file mode 100644 index 00000000..5be38b07 --- /dev/null +++ b/app/api/transactions/[id]/match-rot-rut-payout/__tests__/route.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + createMockRouteParams, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +const mockSettle = vi.fn() +vi.mock('@/lib/invoices/rot-rut-settle', () => ({ + settleRotRutPayoutRequest: (...args: unknown[]) => mockSettle(...args), +})) + +const mockResolveSettlementAccount = vi.fn() +vi.mock('@/lib/bookkeeping/settlement-account', () => ({ + resolveSettlementAccount: (...args: unknown[]) => mockResolveSettlementAccount(...args), +})) + +const mockHasLiveLink = vi.fn() +vi.mock('@/lib/transactions/link-journal-entry', () => ({ + hasLiveJournalEntryLink: (...args: unknown[]) => mockHasLiveLink(...args), +})) + +import { POST } from '../route' + +const TX_ID = '11111111-1111-4111-8111-111111111111' +const REQUEST_ID = '22222222-2222-4222-8222-222222222222' +const mockUser = { id: 'user-1', email: 'test@test.se' } +const routeParams = createMockRouteParams({ id: TX_ID }) + +function makeReq(body: unknown = { request_id: REQUEST_ID }) { + return createMockRequest(`/api/transactions/${TX_ID}/match-rot-rut-payout`, { + method: 'POST', + body, + }) +} + +function makeTxRow(overrides: Record = {}) { + return { + id: TX_ID, + date: '2026-07-10', + amount: 3000, + currency: 'SEK', + journal_entry_id: null, + cash_account_id: 'ca-1', + transaction_voucher_links: [], + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + mockResolveSettlementAccount.mockResolvedValue('1930') + mockHasLiveLink.mockResolvedValue(false) + mockSettle.mockResolvedValue({ + ok: true, + journalEntryId: 'je-1', + amount: 3000, + fullyPaid: true, + request: { id: REQUEST_ID, name: 'ROT 2026-07', status: 'paid' }, + }) +}) + +describe('POST /api/transactions/[id]/match-rot-rut-payout', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await POST(makeReq(), routeParams) + expect(response.status).toBe(401) + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('returns 400 on an invalid body', async () => { + const response = await POST(makeReq({ request_id: 'not-a-uuid' }), routeParams) + expect(response.status).toBe(400) + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('returns 404 when the transaction is not in the company', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + const response = await POST(makeReq(), routeParams) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(404) + expect(body.error.code).toBe('TX_CATEGORIZE_TX_NOT_FOUND') + }) + + it('refuses an expense row', async () => { + enqueue({ data: makeTxRow({ amount: -3000 }) }) + const response = await POST(makeReq(), routeParams) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('ROT_RUT_MATCH_NOT_INCOME') + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('refuses a non-SEK row', async () => { + enqueue({ data: makeTxRow({ currency: 'EUR' }) }) + const response = await POST(makeReq(), routeParams) + const { body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(body.error.code).toBe('ROT_RUT_MATCH_CURRENCY') + }) + + it('refuses a row that is already booked (live pointer or bank_line junction)', async () => { + enqueue({ data: makeTxRow({ journal_entry_id: 'je-old' }) }) + mockHasLiveLink.mockResolvedValue(true) + let response = await POST(makeReq(), routeParams) + let parsed = await parseJsonResponse<{ error: { code: string } }>(response) + expect(parsed.status).toBe(400) + expect(parsed.body.error.code).toBe('ROT_RUT_MATCH_TX_ALREADY_LINKED') + + reset() + enqueue({ + data: makeTxRow({ + transaction_voucher_links: [{ journal_entry_id: 'je-bulk', role: 'bank_line' }], + }), + }) + response = await POST(makeReq(), routeParams) + parsed = await parseJsonResponse<{ error: { code: string } }>(response) + expect(parsed.body.error.code).toBe('ROT_RUT_MATCH_TX_ALREADY_LINKED') + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('settles the request with the transaction amount, date and cash account, linking the row', async () => { + enqueue({ data: makeTxRow() }) + mockResolveSettlementAccount.mockResolvedValue('1920') + + const response = await POST(makeReq(), routeParams) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: string + request: { status: string } + category: string + }>(response) + + expect(status).toBe(200) + expect(body).toMatchObject({ + success: true, + journal_entry_id: 'je-1', + request: { status: 'paid' }, + category: 'income_other', + }) + expect(mockResolveSettlementAccount).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'ca-1', + expect.anything(), + ) + expect(mockSettle).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', { + requestId: REQUEST_ID, + paymentDate: '2026-07-10', + amount: 3000, + bankAccount: '1920', + transactionId: TX_ID, + previousJournalEntryId: null, + }) + }) + + it('forwards a stale (non-live) pointer so the link CAS locks on it', async () => { + enqueue({ data: makeTxRow({ journal_entry_id: 'je-reversed' }) }) + mockHasLiveLink.mockResolvedValue(false) + const response = await POST(makeReq(), routeParams) + expect(response.status).toBe(200) + expect(mockSettle).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'company-1', + expect.objectContaining({ previousJournalEntryId: 'je-reversed' }), + ) + }) + + it('maps service error codes onto the canonical envelope', async () => { + enqueue({ data: makeTxRow() }) + mockSettle.mockResolvedValue({ + ok: false, + kind: 'code', + code: 'ROT_RUT_SETTLE_INVALID_STATE', + details: { status: 'submitted', reason: 'beslut saknas' }, + }) + const response = await POST(makeReq(), routeParams) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('ROT_RUT_SETTLE_INVALID_STATE') + + reset() + enqueue({ data: makeTxRow() }) + mockSettle.mockResolvedValue({ + ok: false, + kind: 'code', + code: 'ROT_RUT_MATCH_TX_LINK_FAILED', + details: { journal_entry_id: 'je-1', request_id: REQUEST_ID }, + }) + const conflict = await POST(makeReq(), routeParams) + expect(conflict.status).toBe(409) + }) +}) diff --git a/app/api/transactions/[id]/match-rot-rut-payout/route.ts b/app/api/transactions/[id]/match-rot-rut-payout/route.ts new file mode 100644 index 00000000..2eb59fa3 --- /dev/null +++ b/app/api/transactions/[id]/match-rot-rut-payout/route.ts @@ -0,0 +1,138 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +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 { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry' +import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked' +import { ensureInitialized } from '@/lib/init' + +ensureInitialized() + +/** + * POST /api/transactions/[id]/match-rot-rut-payout + * + * Match an income bank row to the ROT/RUT begäran whose payout it is: + * + * Debit 19xx (the transaction's cash account) [tx.amount] + * Credit 1513 Skattereduktion rot/rut [tx.amount] + * + * Same settle as POST /api/rot-rut/payout-requests/[id]/settle, but amount, + * 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). + */ +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'transaction.match_rot_rut_payout', + async (request, ctx, { params }) => { + const { id: transactionId } = await params + const { user, supabase, companyId, log, requestId } = ctx + + const validation = await validateBody(request, MatchRotRutPayoutSchema, { + log, + operation: 'transaction.match_rot_rut_payout', + }) + if (!validation.success) return validation.response + const { request_id: payoutRequestId } = validation.data + + const txLog = log.child({ transactionId, payoutRequestId }) + + // transaction_voucher_links rides along: a row bulk-booked into a + // samlingsverifikat carries journal_entry_id = NULL and must still refuse. + const { data: transactionRow, error: fetchTxError } = await supabase + .from('transactions') + .select( + 'id, date, amount, currency, journal_entry_id, cash_account_id, transaction_voucher_links(journal_entry_id, role)', + ) + .eq('id', transactionId) + .eq('company_id', companyId!) + .single() + + if (fetchTxError || !transactionRow) { + return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, { requestId }) + } + const { transaction_voucher_links: junctionLinks, ...transaction } = transactionRow as { + id: string + date: string + amount: number + currency: string | null + journal_entry_id: string | null + cash_account_id: string | null + transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null + } + + if (!(transaction.amount > 0)) { + return errorResponseFromCode('ROT_RUT_MATCH_NOT_INCOME', txLog, { + requestId, + details: { amount: transaction.amount }, + }) + } + + if ((transaction.currency || 'SEK').toUpperCase() !== 'SEK') { + return errorResponseFromCode('ROT_RUT_MATCH_CURRENCY', txLog, { + requestId, + details: { currency: transaction.currency }, + }) + } + + // Only a LIVE (posted) pointer or a bank_line junction row blocks: a + // pointer left behind by a storno reads as "utan koppling" in the UI and + // must stay matchable (same predicate as link-journal-entry, issue #988). + if ( + hasBankLineJunctionRow(junctionLinks) || + (await hasLiveJournalEntryLink(supabase, companyId!, transaction.journal_entry_id)) + ) { + return errorResponseFromCode('ROT_RUT_MATCH_TX_ALREADY_LINKED', txLog, { + requestId, + details: { existingJournalEntryId: transaction.journal_entry_id }, + }) + } + + // Debit the cash account THIS transaction belongs to, never a company-wide + // default (mirrors match-supplier-invoice). + const bankAccount = await resolveSettlementAccount( + supabase, + companyId!, + transaction.cash_account_id, + txLog, + ) + + const outcome = await settleRotRutPayoutRequest(supabase, user.id, companyId!, { + requestId: payoutRequestId, + 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, + }) + + if (!outcome.ok) { + if (outcome.kind === 'code') { + return errorResponseFromCode(outcome.code, txLog, { requestId, details: outcome.details }) + } + if (outcome.stage === 'book') { + txLog.error('failed to book rot/rut payout entry', outcome.error as Error) + } + return errorResponse(outcome.error, txLog, { requestId }) + } + + txLog.info('rot/rut payout matched from bank transaction', { + userId: user.id, + journalEntryId: outcome.journalEntryId, + amount: outcome.amount, + fullyPaid: outcome.fullyPaid, + }) + + return NextResponse.json({ + success: true, + journal_entry_id: outcome.journalEntryId, + request: outcome.request, + category: 'income_other', + }) + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/batch-match-invoices/__tests__/route.test.ts b/app/api/transactions/batch-match-invoices/__tests__/route.test.ts index d908191d..c1823849 100644 --- a/app/api/transactions/batch-match-invoices/__tests__/route.test.ts +++ b/app/api/transactions/batch-match-invoices/__tests__/route.test.ts @@ -14,6 +14,13 @@ vi.mock('@/lib/invoices/invoice-matching', () => ({ getBestInvoiceMatch: (...args: unknown[]) => mockGetBestInvoiceMatch(...args), })) +// Mocked so the open-begäran pool consumes no slot in the queued Supabase mock. +const mockLoadOpenRotRutPayoutRequests = vi.fn() +vi.mock('@/lib/invoices/rot-rut-payout-candidates', () => ({ + loadOpenRotRutPayoutRequests: (...args: unknown[]) => mockLoadOpenRotRutPayoutRequests(...args), +})) +mockLoadOpenRotRutPayoutRequests.mockResolvedValue([]) + vi.mock('@/lib/company/context', () => ({ requireCompanyId: vi.fn().mockResolvedValue('company-1'), getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), diff --git a/app/api/transactions/batch-match-invoices/route.ts b/app/api/transactions/batch-match-invoices/route.ts index e1c7633b..156a89a2 100644 --- a/app/api/transactions/batch-match-invoices/route.ts +++ b/app/api/transactions/batch-match-invoices/route.ts @@ -1,11 +1,16 @@ import { NextResponse } from 'next/server' import { withRouteContext } from '@/lib/api/with-route-context' import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching' +import { findRotRutPayoutMatch } from '@/lib/invoices/rot-rut-payout-matching' +import { loadOpenRotRutPayoutRequests } from '@/lib/invoices/rot-rut-payout-candidates' import type { Transaction } from '@/types' /** * POST /api/transactions/batch-match-invoices - * Run invoice matching for all uncategorized income transactions without potential_invoice_id + * Run invoice matching for all uncategorized income transactions without potential_invoice_id. + * Rows that match no invoice are then tried against open ROT/RUT payout + * requests (Skatteverkets utbetalning), so bank rows imported before that + * hint existed still get a suggestion on the next run. */ export const POST = withRouteContext( 'transaction.batch_match_invoices', @@ -26,6 +31,9 @@ export const POST = withRouteContext( return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 }) } + // Open begäran, loaded once: the matcher is pure and the table is tiny. + let openRotRutRequests = await loadOpenRotRutPayoutRequests(supabase, companyId!) + let matched = 0 const matchedInvoiceIds = new Set() @@ -46,6 +54,25 @@ export const POST = withRouteContext( matchedInvoiceIds.add(bestMatch.invoice.id) matched++ + continue + } + + if ( + openRotRutRequests.length > 0 && + !(tx as Transaction).potential_rot_rut_payout_request_id + ) { + const payoutMatch = findRotRutPayoutMatch(tx as Transaction, openRotRutRequests) + if (payoutMatch) { + const { error: hintError } = await supabase + .from('transactions') + .update({ potential_rot_rut_payout_request_id: payoutMatch.request.id }) + .eq('id', tx.id) + // A hint that never persisted must not drain the pool or count. + if (hintError) throw hintError + // One payout per begäran: drain so a same-amount sibling can't claim it. + openRotRutRequests = openRotRutRequests.filter((r) => r.id !== payoutMatch.request.id) + matched++ + } } } catch { // Continue with other transactions diff --git a/components/dashboard/AttGoraSection.tsx b/components/dashboard/AttGoraSection.tsx index 0b4cc376..170e90db 100644 --- a/components/dashboard/AttGoraSection.tsx +++ b/components/dashboard/AttGoraSection.tsx @@ -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({ {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}` : ''} {' · '} diff --git a/components/transactions/InvoicePicker.tsx b/components/transactions/InvoicePicker.tsx index cbfbaded..b46add9b 100644 --- a/components/transactions/InvoicePicker.tsx +++ b/components/transactions/InvoicePicker.tsx @@ -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([]) + const [rotRutRequests, setRotRutRequests] = useState([]) 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 ? ( +
+

+ {t('rot_rut_section_title')} +

+ {rotRutRequests.map((request) => { + const expected = expectedRotRutPayoutAmount(request) + const exact = Math.abs(expected - txAmount) < 0.005 + return ( + + ) + })} +
+ ) : null + if (invoices.length === 0) { return ( -
-

{t('empty')}

+
+ {rotRutSection} +
+

{t('empty')}

+
) } return (
+ {rotRutSection}
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 ( + + + + {t('title')} + + {targetBlocked ? t('description_blocked') : t('description')} + + + + {transaction && request && ( +
+
+

{t('transaction_label')}

+

{transaction.description}

+
+ {formatDate(transaction.date)} + + +{formatCurrency(transaction.amount, currency)} + +
+
+ +
+

{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')} + +
  • + ))} +
+
+ )} +
+ + {targetBlocked ? ( +
+ +
+

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

+

+ {t( + targetState === 'settled' + ? 'target_settled_description' + : 'target_not_open_description', + )} +

+
+
+ ) : amountsMatch ? ( +
+ +

{t('amounts_match')}

+
+ ) : ( +
+ +
+

{t('amounts_differ')}

+

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

+ {overBlocked &&

{t('over_payout_blocked')}

} + {partialBlocked &&

{t('partial_requires_beslut')}

} + {isPartial && !partialBlocked && ( +

{t('partial_with_beslut_note')}

+ )} +
+
+ )} + + {!targetBlocked && ( +
+

{t('booking_title')}

+
+
+ + {t('booking_debit')}{' '} + {t('booking_bank_line')} + + {formatCurrency(txAmount, currency)} +
+
+ + {t('booking_credit')}{' '} + {t('booking_receivable_line')} + + {formatCurrency(txAmount, currency)} +
+
+
+ )} + + {!targetBlocked && ( +
+

{t('on_confirm_title')}

+
    +
  • • {t('on_confirm_link')}
  • +
  • • {t('on_confirm_request')}
  • +
  • • {t('on_confirm_voucher')}
  • +
+
+ )} +
+ )} + + + + + +
+
+ ) +} diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 20cc68ff..8b97fb2c 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -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' diff --git a/components/transactions/transaction-types.ts b/components/transactions/transaction-types.ts index 55d06ac6..ca4ba667 100644 --- a/components/transactions/transaction-types.ts +++ b/components/transactions/transaction-types.ts @@ -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 } diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 67111c0e..0b802f5e 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -2129,6 +2129,15 @@ export const CreateTransactionFromDocumentSchema = z.object({ description: z.string().min(1).max(500), }) +/** + * POST /api/transactions/[id]/match-rot-rut-payout: settle a ROT/RUT begäran + * with the bank row that carried Skatteverkets utbetalning. Amount, date and + * bank account all come from the transaction, so the body is just the target. + */ +export const MatchRotRutPayoutSchema = z.object({ + request_id: uuid, +}) + export const MatchSupplierInvoiceSchema = z.object({ supplier_invoice_id: uuid, // Same purpose as MatchInvoiceSchema.lines: user-edited rows override diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 4ce4d8cc..cd77dac3 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -959,6 +959,42 @@ const INVOICE: Record = { message_sv: 'Utbetalningen kan bara bokföras för en inskickad begäran som inte redan är bokförd.', message_en: 'The payout can only be booked for a submitted request that is not already settled.', }, + ROT_RUT_SETTLE_AMOUNT_EXCEEDS: { + httpStatus: 400, + message_sv: + 'Beloppet kan inte bokföras mot begäran: det är större än begärt eller beslutat belopp. Bokför transaktionen på annat sätt.', + message_en: + 'The amount cannot be booked against the request: it exceeds the requested or decided amount. Book the transaction another way.', + }, + ROT_RUT_SETTLE_RACE: { + httpStatus: 409, + message_sv: + 'Begäran hann redan bokföras som utbetald av en annan åtgärd. Verifikationen som skapades kan inte kopplas: kontrollera bokföringen på konto 1513.', + message_en: + 'The request was already settled by another action. The voucher that was created could not be attached: check the bookkeeping on account 1513.', + }, + ROT_RUT_MATCH_NOT_INCOME: { + httpStatus: 400, + message_sv: 'Endast inbetalningar kan matchas mot en ROT/RUT-utbetalning från Skatteverket.', + message_en: 'Only income transactions can be matched to a ROT/RUT payout from Skatteverket.', + }, + ROT_RUT_MATCH_TX_ALREADY_LINKED: { + httpStatus: 400, + message_sv: 'Transaktionen är redan bokförd eller kopplad till en verifikation.', + message_en: 'The transaction is already booked or linked to a journal entry.', + }, + ROT_RUT_MATCH_CURRENCY: { + httpStatus: 400, + message_sv: 'Transaktionen kan inte matchas: Skatteverket betalar ut i SEK och transaktionen har en annan valuta.', + message_en: 'Skatteverket pays out in SEK; the transaction is in another currency.', + }, + ROT_RUT_MATCH_TX_LINK_FAILED: { + httpStatus: 409, + message_sv: + 'Utbetalningen bokfördes men transaktionen kunde inte kopplas till verifikationen. Koppla den via "Matcha mot befintlig verifikation".', + message_en: + 'The payout was booked but the transaction could not be linked to the voucher. Link it via "Match against existing voucher".', + }, ROT_RUT_FILE_CREATE_FAILED: { httpStatus: 500, message_sv: 'Filen kunde inte skapas.', diff --git a/lib/invoices/__tests__/rot-rut-payout-matching.test.ts b/lib/invoices/__tests__/rot-rut-payout-matching.test.ts new file mode 100644 index 00000000..f8b70732 --- /dev/null +++ b/lib/invoices/__tests__/rot-rut-payout-matching.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest' +import { makeTransaction } from '@/tests/helpers' +import { + expectedRotRutPayoutAmount, + findRotRutPayoutMatch, + getRotRutPayoutMatchTargetState, + isMatchableRotRutPayoutRequest, + mentionsSkatteverket, + type RotRutPayoutRequestCandidate, +} from '../rot-rut-payout-matching' + +function makeRequest( + overrides: Partial = {}, +): RotRutPayoutRequestCandidate { + return { + id: 'rr-1', + name: 'ROT 2026-07', + deduction_type: 'rot', + status: 'submitted', + requested_total: 3000, + decided_total: null, + settlement_journal_entry_id: null, + ...overrides, + } +} + +describe('expectedRotRutPayoutAmount', () => { + it('prefers the recorded beslut over the requested total', () => { + expect(expectedRotRutPayoutAmount(makeRequest())).toBe(3000) + expect(expectedRotRutPayoutAmount(makeRequest({ decided_total: 2500 }))).toBe(2500) + }) + + it('coerces PostgREST NUMERIC strings', () => { + expect(expectedRotRutPayoutAmount(makeRequest({ requested_total: '3000.00' }))).toBe(3000) + expect(expectedRotRutPayoutAmount(makeRequest({ decided_total: '2499.995' }))).toBe(2500) + }) +}) + +describe('getRotRutPayoutMatchTargetState', () => { + it('is matchable for open, unsettled requests, including a voucher-less paid beslut', () => { + for (const status of ['generated', 'submitted', 'partially_paid', 'paid']) { + expect(getRotRutPayoutMatchTargetState(makeRequest({ status }))).toBe('matchable') + } + }) + + it('is settled only once a settlement voucher exists', () => { + expect( + getRotRutPayoutMatchTargetState(makeRequest({ settlement_journal_entry_id: 'je-1' })), + ).toBe('settled') + expect( + getRotRutPayoutMatchTargetState( + makeRequest({ status: 'paid', settlement_journal_entry_id: 'je-1' }), + ), + ).toBe('settled') + }) + + it('suggests a voucher-less paid request for its payout (beslut recorded via PATCH)', () => { + const tx = makeTransaction({ amount: 2500, description: 'Skatteverket' }) + const request = makeRequest({ status: 'paid', decided_total: 2500 }) + expect(findRotRutPayoutMatch(tx, [request])?.request.id).toBe('rr-1') + }) + + it('is not_open for cancelled, rejected or missing requests', () => { + expect(getRotRutPayoutMatchTargetState(makeRequest({ status: 'cancelled' }))).toBe('not_open') + expect(getRotRutPayoutMatchTargetState(makeRequest({ status: 'rejected' }))).toBe('not_open') + expect(getRotRutPayoutMatchTargetState(null)).toBe('not_open') + expect(isMatchableRotRutPayoutRequest(undefined)).toBe(false) + }) +}) + +describe('mentionsSkatteverket', () => { + it('matches the agency name or the SKV abbreviation in description or merchant', () => { + expect(mentionsSkatteverket({ amount: 1, description: 'Utbetalning SKATTEVERKET' })).toBe(true) + expect(mentionsSkatteverket({ amount: 1, description: 'Ins. SKV rot' })).toBe(true) + expect(mentionsSkatteverket({ amount: 1, merchant_name: 'Skatteverket' })).toBe(true) + expect(mentionsSkatteverket({ amount: 1, description: 'Kund AB faktura 12' })).toBe(false) + // "skvadron" must not count as SKV: the abbreviation needs word boundaries. + expect(mentionsSkatteverket({ amount: 1, description: 'Skvadron AB' })).toBe(false) + }) +}) + +describe('findRotRutPayoutMatch', () => { + it('returns null with no open requests', () => { + expect(findRotRutPayoutMatch(makeTransaction({ amount: 3000 }), [])).toBeNull() + }) + + it('matches an exact-amount income row at 0.85', () => { + const tx = makeTransaction({ amount: 3000, description: 'Insättning' }) + const match = findRotRutPayoutMatch(tx, [makeRequest()]) + expect(match).toEqual({ request: makeRequest(), confidence: 0.85, matchMethod: 'amount' }) + }) + + it('boosts to 0.95 when Skatteverket is named', () => { + const tx = makeTransaction({ amount: 3000, description: 'Skatteverket utbetalning' }) + const match = findRotRutPayoutMatch(tx, [makeRequest()]) + expect(match?.confidence).toBe(0.95) + expect(match?.matchMethod).toBe('amount_skatteverket') + }) + + it('compares against the beslut amount when one is recorded', () => { + const tx = makeTransaction({ amount: 2500, description: 'Skatteverket' }) + const request = makeRequest({ decided_total: 2500 }) + expect(findRotRutPayoutMatch(tx, [request])?.request.id).toBe('rr-1') + expect(findRotRutPayoutMatch(makeTransaction({ amount: 3000 }), [request])).toBeNull() + }) + + it('never fuzzy-matches: an öre off is not this request', () => { + const tx = makeTransaction({ amount: 3000.01, description: 'Skatteverket' }) + expect(findRotRutPayoutMatch(tx, [makeRequest()])).toBeNull() + }) + + it('ignores expenses, non-SEK rows and unmatchable requests', () => { + expect(findRotRutPayoutMatch(makeTransaction({ amount: -3000 }), [makeRequest()])).toBeNull() + expect( + findRotRutPayoutMatch(makeTransaction({ amount: 3000, currency: 'EUR' as never }), [makeRequest()]), + ).toBeNull() + expect( + findRotRutPayoutMatch(makeTransaction({ amount: 3000 }), [ + makeRequest({ settlement_journal_entry_id: 'je-1' }), + ]), + ).toBeNull() + expect( + findRotRutPayoutMatch(makeTransaction({ amount: 3000 }), [makeRequest({ status: 'cancelled' })]), + ).toBeNull() + }) + + it('treats a NULL currency as SEK (legacy bank rows)', () => { + const tx = makeTransaction({ amount: 3000, currency: null as never }) + expect(findRotRutPayoutMatch(tx, [makeRequest()])?.request.id).toBe('rr-1') + }) + + it('refuses to guess between two open requests with the same amount', () => { + const tx = makeTransaction({ amount: 3000, description: 'Skatteverket' }) + const pool = [makeRequest({ id: 'rr-1' }), makeRequest({ id: 'rr-2', name: 'RUT 2026-07', deduction_type: 'rut' })] + expect(findRotRutPayoutMatch(tx, pool)).toBeNull() + // ...but picks the unique hit when the other differs in amount. + const distinct = [makeRequest({ id: 'rr-1' }), makeRequest({ id: 'rr-2', requested_total: 4500 })] + expect(findRotRutPayoutMatch(tx, distinct)?.request.id).toBe('rr-1') + }) +}) diff --git a/lib/invoices/__tests__/rot-rut-settle.test.ts b/lib/invoices/__tests__/rot-rut-settle.test.ts new file mode 100644 index 00000000..8656fc9d --- /dev/null +++ b/lib/invoices/__tests__/rot-rut-settle.test.ts @@ -0,0 +1,321 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { createQueuedMockSupabase } from '@/tests/helpers' + +const mockCreatePayoutEntry = vi.fn() +vi.mock('@/lib/bookkeeping/rot-rut-entries', () => ({ + createRotRutPayoutEntry: (...args: unknown[]) => mockCreatePayoutEntry(...args), +})) + +const mockLogMatchEvent = vi.fn() +vi.mock('@/lib/invoices/match-log', () => ({ + logMatchEvent: (...args: unknown[]) => mockLogMatchEvent(...args), +})) + +// Mocked so the sibling-hint sweep consumes no slot in the queued mock; its +// query shape is pinned by clear-settled-invoice-suggestions.test.ts. +const mockClearSuggestions = vi.fn() +vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({ + clearSettledInvoiceSuggestions: (...args: unknown[]) => mockClearSuggestions(...args), +})) + +const mockPropagateUnderlag = vi.fn() +vi.mock('@/lib/transactions/inbox-underlag', () => ({ + propagateUnderlagForBookedTransaction: (...args: unknown[]) => mockPropagateUnderlag(...args), +})) + +import { settleRotRutPayoutRequest } from '../rot-rut-settle' + +const { supabase: mockSupabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase() +const supabase = mockSupabase as unknown as SupabaseClient + +const REQUEST_ID = '22222222-2222-4222-8222-222222222222' +const TX_ID = '11111111-1111-4111-8111-111111111111' + +function makeRequestRow(overrides: Record = {}) { + return { + id: REQUEST_ID, + company_id: 'company-1', + name: 'ROT 2026-07', + deduction_type: 'rot', + status: 'submitted', + requested_total: 3000, + decided_total: null, + decided_at: null, + settlement_journal_entry_id: null, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + mockCreatePayoutEntry.mockResolvedValue({ id: 'je-1' }) +}) + +describe('settleRotRutPayoutRequest', () => { + it('returns ROT_RUT_REQUEST_NOT_FOUND for an unknown request', async () => { + enqueue({ data: null }) + const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', { + requestId: REQUEST_ID, + paymentDate: '2026-07-10', + }) + expect(outcome).toEqual({ ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' }) + expect(mockCreatePayoutEntry).not.toHaveBeenCalled() + }) + + it('refuses an already settled or cancelled request before booking anything', async () => { + enqueue({ data: makeRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-0' }) }) + const settled = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', { + requestId: REQUEST_ID, + paymentDate: '2026-07-10', + }) + expect(settled.ok).toBe(false) + expect(settled).toMatchObject({ code: 'ROT_RUT_SETTLE_INVALID_STATE' }) + + enqueue({ data: makeRequestRow({ status: 'cancelled' }) }) + const cancelled = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', { + requestId: REQUEST_ID, + paymentDate: '2026-07-10', + }) + expect(cancelled).toMatchObject({ ok: false, code: 'ROT_RUT_SETTLE_INVALID_STATE' }) + expect(mockCreatePayoutEntry).not.toHaveBeenCalled() + }) + + it('refuses a partial payout when no beslut is recorded', async () => { + enqueue({ data: makeRequestRow() }) + const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', { + requestId: REQUEST_ID, + paymentDate: '2026-07-10', + amount: 2500, + }) + expect(outcome).toMatchObject({ ok: false, code: 'ROT_RUT_SETTLE_INVALID_STATE' }) + expect(mockCreatePayoutEntry).not.toHaveBeenCalled() + }) + + it('refuses a payout above the requested (or decided) amount before booking', async () => { + enqueue({ data: makeRequestRow() }) + const over = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', { + requestId: REQUEST_ID, + paymentDate: '2026-07-10', + amount: 12500, + }) + expect(over).toEqual({ + ok: false, + kind: 'code', + code: 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS', + details: { amount: 12500, expected_amount: 3000, status: 'submitted' }, + }) + + // With a beslut recorded, the beslut is the ceiling. + reset() + enqueue({ data: makeRequestRow({ decided_total: 2500 }) }) + const overDecided = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', { + requestId: REQUEST_ID, + paymentDate: '2026-07-10', + amount: 3000, + }) + expect(overDecided).toMatchObject({ ok: false, code: 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS' }) + expect(mockCreatePayoutEntry).not.toHaveBeenCalled() + }) + + it('reports ROT_RUT_SETTLE_RACE when another settle attached first, keeping the voucher', async () => { + enqueue({ data: makeRequestRow() }) + enqueue({ data: null }) // request CAS on settlement_journal_entry_id IS NULL matched 0 rows + + const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', { + requestId: REQUEST_ID, + paymentDate: '2026-07-10', + transactionId: TX_ID, + }) + + expect(outcome).toEqual({ + ok: false, + kind: 'code', + code: 'ROT_RUT_SETTLE_RACE', + details: { journal_entry_id: 'je-1', request_id: REQUEST_ID }, + }) + expect(findCall('rot_rut_payout_requests', 'is')).toEqual(['settlement_journal_entry_id', null]) + // The loser never touches the bank row. + expect(findCalls('transactions', 'update')).toEqual([]) + }) + + it('locks the link on a stale pointer when the route passes one (issue #988 rows)', async () => { + enqueue({ data: makeRequestRow() }) + enqueue({ + data: makeRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-1', decided_total: 3000 }), + }) + enqueue({ data: [{ id: TX_ID }] }) + enqueue({ data: [] }) + + const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', { + requestId: REQUEST_ID, + paymentDate: '2026-07-10', + amount: 3000, + transactionId: TX_ID, + previousJournalEntryId: 'je-reversed', + }) + + expect(outcome.ok).toBe(true) + const eqCalls = findCalls('transactions', 'eq') + expect(eqCalls).toContainEqual(['journal_entry_id', 'je-reversed']) + expect(findCall('transactions', 'is')).toBeUndefined() + }) + + it('books the voucher, completes the request and mirrors decided_amount (headless)', async () => { + enqueue({ data: makeRequestRow() }) + enqueue({ + data: makeRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-1', decided_total: 3000 }), + }) + enqueue({ data: [{ id: 'item-1', requested_amount: 3000 }] }) + enqueue({ data: null }) + + const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', { + requestId: REQUEST_ID, + paymentDate: '2026-07-10', + bankAccount: '1920', + }) + + expect(outcome).toMatchObject({ ok: true, journalEntryId: 'je-1', amount: 3000, fullyPaid: true }) + expect(mockCreatePayoutEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ + requestId: REQUEST_ID, + amount: 3000, + paymentDate: '2026-07-10', + bankAccount: '1920', + }), + ) + const requestUpdate = findCall('rot_rut_payout_requests', 'update')?.[0] as Record + expect(requestUpdate).toMatchObject({ + settlement_journal_entry_id: 'je-1', + status: 'paid', + decided_total: 3000, + }) + // No transaction was passed: nothing is written to transactions. + expect(findCalls('transactions', 'update')).toEqual([]) + expect(mockLogMatchEvent).not.toHaveBeenCalled() + expect(mockClearSuggestions).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'rot_rut_payout_request', + REQUEST_ID, + { exceptTransactionId: null }, + ) + }) + + it('links the bank transaction to the settlement voucher and clears its hints', async () => { + enqueue({ data: makeRequestRow() }) + enqueue({ + data: makeRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-1', decided_total: 3000 }), + }) + enqueue({ data: [{ id: TX_ID }] }) // transactions CAS update + enqueue({ data: [] }) // items + // payment_match_log insert is mocked (logMatchEvent) + + const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', { + requestId: REQUEST_ID, + paymentDate: '2026-07-10', + amount: 3000, + bankAccount: '1930', + transactionId: TX_ID, + }) + + expect(outcome.ok).toBe(true) + const txUpdate = findCall('transactions', 'update')?.[0] as Record + expect(txUpdate).toEqual({ + journal_entry_id: 'je-1', + is_business: true, + category: 'income_other', + potential_invoice_id: null, + potential_supplier_invoice_id: null, + potential_rot_rut_payout_request_id: null, + reconciliation_method: null, + }) + // Optimistic lock: only a free row absorbs the link. + expect(findCall('transactions', 'is')).toEqual(['journal_entry_id', null]) + expect(mockLogMatchEvent).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + TX_ID, + 'matched', + expect.objectContaining({ + matchMethod: 'rot_rut_payout_manual_confirm', + newState: expect.objectContaining({ journal_entry_id: 'je-1', rot_rut_payout_request_id: REQUEST_ID }), + }), + ) + expect(mockClearSuggestions).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'rot_rut_payout_request', + REQUEST_ID, + { exceptTransactionId: TX_ID }, + ) + }) + + it('reports ROT_RUT_MATCH_TX_LINK_FAILED when the optimistic lock loses, keeping the voucher', async () => { + enqueue({ data: makeRequestRow() }) + enqueue({ + data: makeRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-1', decided_total: 3000 }), + }) + enqueue({ data: [] }) // CAS matched 0 rows: someone booked the row meanwhile + + const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', { + requestId: REQUEST_ID, + paymentDate: '2026-07-10', + amount: 3000, + transactionId: TX_ID, + }) + + expect(outcome).toEqual({ + ok: false, + kind: 'code', + code: 'ROT_RUT_MATCH_TX_LINK_FAILED', + details: { journal_entry_id: 'je-1', request_id: REQUEST_ID }, + }) + expect(mockLogMatchEvent).not.toHaveBeenCalled() + }) + + it('records a partial payout as partially_paid once a beslut exists', async () => { + enqueue({ data: makeRequestRow({ decided_total: 2500, decided_at: '2026-07-01T00:00:00Z' }) }) + enqueue({ + data: makeRequestRow({ + status: 'partially_paid', + settlement_journal_entry_id: 'je-2', + decided_total: 2500, + }), + }) + mockCreatePayoutEntry.mockResolvedValue({ id: 'je-2' }) + + const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', { + requestId: REQUEST_ID, + paymentDate: '2026-07-10', + amount: 2500, + }) + + expect(outcome).toMatchObject({ ok: true, journalEntryId: 'je-2', amount: 2500, fullyPaid: false }) + const requestUpdate = findCall('rot_rut_payout_requests', 'update')?.[0] as Record + expect(requestUpdate).toEqual({ + settlement_journal_entry_id: 'je-2', + status: 'partially_paid', + decided_total: 2500, + }) + // No item mirror on a partial payout. + expect(findCalls('rot_rut_payout_request_items', 'update')).toEqual([]) + }) + + it('surfaces an engine failure as a raw error without touching the request', async () => { + enqueue({ data: makeRequestRow() }) + mockCreatePayoutEntry.mockRejectedValue(new Error('No open fiscal period')) + + const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', { + requestId: REQUEST_ID, + paymentDate: '2026-07-10', + }) + + expect(outcome).toMatchObject({ ok: false, kind: 'error', stage: 'book' }) + expect(findCalls('rot_rut_payout_requests', 'update')).toEqual([]) + }) +}) diff --git a/lib/invoices/clear-settled-invoice-suggestions.ts b/lib/invoices/clear-settled-invoice-suggestions.ts index 25ff0d97..59d1088c 100644 --- a/lib/invoices/clear-settled-invoice-suggestions.ts +++ b/lib/invoices/clear-settled-invoice-suggestions.ts @@ -3,7 +3,13 @@ import { createLogger } from '@/lib/logger' const log = createLogger('invoices/clear-settled-invoice-suggestions') -export type SettledInvoiceKind = 'invoice' | 'supplier_invoice' +export type SettledInvoiceKind = 'invoice' | 'supplier_invoice' | 'rot_rut_payout_request' + +const HINT_COLUMN_BY_KIND: Record = { + invoice: 'potential_invoice_id', + supplier_invoice: 'potential_supplier_invoice_id', + rot_rut_payout_request: 'potential_rot_rut_payout_request_id', +} /** * Retire the match SUGGESTIONS that point at an invoice which has just been @@ -36,7 +42,7 @@ export async function clearSettledInvoiceSuggestions( invoiceId: string, options?: { exceptTransactionId?: string | null }, ): Promise { - const column = kind === 'invoice' ? 'potential_invoice_id' : 'potential_supplier_invoice_id' + const column = HINT_COLUMN_BY_KIND[kind] let query = supabase .from('transactions') .update({ [column]: null }) diff --git a/lib/invoices/rot-rut-payout-candidates.ts b/lib/invoices/rot-rut-payout-candidates.ts new file mode 100644 index 00000000..38417b68 --- /dev/null +++ b/lib/invoices/rot-rut-payout-candidates.ts @@ -0,0 +1,31 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { + OPEN_ROT_RUT_PAYOUT_STATUSES, + type RotRutPayoutRequestCandidate, +} from './rot-rut-payout-matching' + +/** + * Open, unsettled ROT/RUT begäran for a company: the candidate pool for + * matching Skatteverkets utbetalning against a bank row. + * + * Own module so import-time callers (bank ingest, the batch re-suggest route) + * can be unit-tested with the pool mocked, the same way getBestInvoiceMatch + * is. Non-fatal: any error yields an empty pool and matching is skipped. + */ +export async function loadOpenRotRutPayoutRequests( + supabase: SupabaseClient, + companyId: string, +): Promise { + try { + const { data, error } = await supabase + .from('rot_rut_payout_requests') + .select('id, name, deduction_type, status, requested_total, decided_total, settlement_journal_entry_id') + .eq('company_id', companyId) + .in('status', [...OPEN_ROT_RUT_PAYOUT_STATUSES]) + .is('settlement_journal_entry_id', null) + if (error) return [] + return (data ?? []) as RotRutPayoutRequestCandidate[] + } catch { + return [] + } +} diff --git a/lib/invoices/rot-rut-payout-matching.ts b/lib/invoices/rot-rut-payout-matching.ts new file mode 100644 index 00000000..9986b41a --- /dev/null +++ b/lib/invoices/rot-rut-payout-matching.ts @@ -0,0 +1,137 @@ +/** + * ROT/RUT payout matching: suggest the open begäran om utbetalning that an + * income bank row from Skatteverket settles. + * + * Why the payout REQUEST and not the invoice: under fakturamodellen the + * customer pays their share (settles the invoice, remaining_amount is stored + * net of the deduction) and Skatteverket's share sits on BAS 1513 until the + * agency pays out one lump sum per begäran, which may cover several invoices. + * The invoice is therefore already `paid` when the SKV money lands and can + * never be a match candidate; the request is the thing that still has an + * outstanding balance. + * + * Confidence ladder (mirrors supplier-invoice-matching.ts): + * exact amount + Skatteverket named in description/merchant -> 0.95 + * exact amount only -> 0.85 + * Two open requests with the same amount are ambiguous: no suggestion. There + * is no fuzzy amount pass on purpose: Skatteverket pays exactly the decided + * sum, so a near-miss is not this request. + * + * No server-only imports on purpose (only lib/money): client components + * import the target-state helper too. + */ + +import { roundOre } from '@/lib/money' + +/** + * Statuses that can still absorb a payout. `paid` is included on purpose: the + * PATCH lifecycle records Skatteverkets beslut as `paid` BEFORE the money is + * booked, so a voucher-less `paid` request is exactly the one waiting for its + * bank row. Settled means "has a settlement voucher", never a status alone. + */ +export const OPEN_ROT_RUT_PAYOUT_STATUSES = [ + 'generated', + 'submitted', + 'paid', + 'partially_paid', +] as const + +/** + * The columns the matcher and the match dialog need from + * rot_rut_payout_requests. Numeric columns arrive as strings from PostgREST + * (NUMERIC), so callers must coerce with Number() where they read them. + */ +export interface RotRutPayoutRequestCandidate { + 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 +} + +export interface RotRutPayoutMatch { + request: T + confidence: number + matchMethod: 'amount_skatteverket' | 'amount' +} + +export type RotRutPayoutMatchTargetState = 'matchable' | 'settled' | 'not_open' + +const SKATTEVERKET_PATTERN = /skatteverket|\bskv\b/i + +/** The amount Skatteverket is expected to pay: the beslut when recorded, else the request. */ +export function expectedRotRutPayoutAmount(request: RotRutPayoutRequestCandidate): number { + const raw = request.decided_total ?? request.requested_total + return roundOre(Number(raw)) +} + +/** + * Can this request still absorb a payout? Mirrors the settle service's guard: + * no settlement voucher yet and not cancelled/rejected. A voucher-less `paid` + * (beslut recorded via PATCH, money not yet booked) is matchable. + */ +export function getRotRutPayoutMatchTargetState( + request: RotRutPayoutRequestCandidate | null | undefined, +): RotRutPayoutMatchTargetState { + if (!request) return 'not_open' + if (request.settlement_journal_entry_id) return 'settled' + if (!(OPEN_ROT_RUT_PAYOUT_STATUSES as readonly string[]).includes(request.status)) return 'not_open' + return 'matchable' +} + +export function isMatchableRotRutPayoutRequest( + request: RotRutPayoutRequestCandidate | null | undefined, +): boolean { + return getRotRutPayoutMatchTargetState(request) === 'matchable' +} + +interface MatchableTransaction { + amount: number + currency?: string | null + description?: string | null + merchant_name?: string | null +} + +/** + * Does the bank row name Skatteverket? Used both to boost confidence and, in + * the dialog, to explain why the suggestion fired. + */ +export function mentionsSkatteverket(transaction: MatchableTransaction): boolean { + return ( + SKATTEVERKET_PATTERN.test(transaction.description ?? '') || + SKATTEVERKET_PATTERN.test(transaction.merchant_name ?? '') + ) +} + +/** + * Find the open payout request an income transaction settles, or null. + * Pure: the caller loads the open requests once per import batch. + */ +export function findRotRutPayoutMatch( + transaction: MatchableTransaction, + openRequests: T[], +): RotRutPayoutMatch | null { + if (openRequests.length === 0) return null + // Skatteverket pays in kronor only. A NULL currency on a legacy bank row + // means SEK (transactions.currency DEFAULT 'SEK'). + if ((transaction.currency || 'SEK').toUpperCase() !== 'SEK') return null + if (!(transaction.amount > 0)) return null + + const txAmount = roundOre(transaction.amount) + const hits = openRequests.filter( + (request) => + isMatchableRotRutPayoutRequest(request) && + Math.abs(expectedRotRutPayoutAmount(request) - txAmount) < 0.005, + ) + // Ambiguous: the amount alone cannot pick between two begäran. Never guess. + if (hits.length !== 1) return null + + const named = mentionsSkatteverket(transaction) + return { + request: hits[0], + confidence: named ? 0.95 : 0.85, + matchMethod: named ? 'amount_skatteverket' : 'amount', + } +} diff --git a/lib/invoices/rot-rut-settle.ts b/lib/invoices/rot-rut-settle.ts new file mode 100644 index 00000000..984b527f --- /dev/null +++ b/lib/invoices/rot-rut-settle.ts @@ -0,0 +1,335 @@ +/** + * Settle a ROT/RUT begäran: book Skatteverkets utbetalning and, optionally, + * link the bank transaction that carried it. + * + * Debit 19xx bank account (default 1930) [amount] + * Credit 1513 Skattereduktion rot/rut [amount] + * + * Shared between two callers: + * - REST: app/api/rot-rut/payout-requests/[id]/settle/route.ts + * (headless settle: amount/date/bank account supplied by the caller) + * - REST: app/api/transactions/[id]/match-rot-rut-payout/route.ts + * (bank-row match: amount/date/bank account come from the transaction, + * and the row is linked to the settlement voucher in the same call) + * + * The journal entry IS the accounting record: engine failure blocks the whole + * operation. Everything after the voucher is best-effort-with-loud-logging, + * never an unbook (the voucher is immutable per BFL). + */ +import type { SupabaseClient } from '@supabase/supabase-js' +import { createRotRutPayoutEntry } from '@/lib/bookkeeping/rot-rut-entries' +import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions' +import { logMatchEvent } from '@/lib/invoices/match-log' +import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag' +import { roundOre } from '@/lib/money' +import { createLogger } from '@/lib/logger' + +const log = createLogger('invoices/rot-rut-settle') + +export interface SettleRotRutPayoutParams { + requestId: string + paymentDate: string + /** Defaults to decided_total ?? requested_total. */ + amount?: number + /** BAS 19xx account the payout landed on. Defaults to 1930 in the engine. */ + bankAccount?: string + /** + * Bank transaction that carried the payout. When set, the row is linked to + * the settlement voucher (journal_entry_id) and its match hints are cleared. + * The caller must have verified the row is unbooked and belongs to the + * company; this function re-checks with an optimistic lock on + * journal_entry_id IS NULL. + */ + transactionId?: string + /** + * The transaction's journal_entry_id as the caller read it: null for a free + * row, or the STALE id of a reversed/cancelled entry the route judged not + * live (issue #988). The link CAS locks on exactly that value, so a stale + * pointer can be overwritten while a concurrent live link still turns the + * write into a no-op (same contract as link-journal-entry.ts). + */ + previousJournalEntryId?: string | null +} + +export interface SettledRotRutPayoutRequest { + id: string + name: string + deduction_type: 'rot' | 'rut' + status: string + requested_total: number | string + decided_total: number | string | null + decided_at: string | null + settlement_journal_entry_id: string | null +} + +export type SettleRotRutPayoutErrorCode = + | 'ROT_RUT_REQUEST_NOT_FOUND' + | 'ROT_RUT_SETTLE_INVALID_STATE' + | 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS' + | 'ROT_RUT_SETTLE_RACE' + | 'ROT_RUT_MATCH_TX_LINK_FAILED' + +export type SettleRotRutPayoutOutcome = + | { + ok: true + request: SettledRotRutPayoutRequest + journalEntryId: string + amount: number + fullyPaid: boolean + } + | { ok: false; kind: 'code'; code: SettleRotRutPayoutErrorCode; details?: Record } + /** A raw Supabase/engine error the route maps through errorResponse(). */ + | { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update' } + +export async function settleRotRutPayoutRequest( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: SettleRotRutPayoutParams, +): Promise { + const { data: payoutRequest, error: fetchError } = await supabase + .from('rot_rut_payout_requests') + .select('*') + .eq('company_id', companyId) + .eq('id', params.requestId) + .maybeSingle() + + if (fetchError) { + return { ok: false, kind: 'error', error: fetchError, stage: 'fetch' } + } + if (!payoutRequest) { + return { ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' } + } + + const settleable = + !payoutRequest.settlement_journal_entry_id && + !['cancelled', 'rejected'].includes(payoutRequest.status) + if (!settleable) { + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_SETTLE_INVALID_STATE', + details: { + status: payoutRequest.status, + already_settled: !!payoutRequest.settlement_journal_entry_id, + }, + } + } + + const amount = + params.amount ?? Number(payoutRequest.decided_total ?? payoutRequest.requested_total) + + // A partial settlement must follow a recorded beslut: without this guard a + // settle with amount < requested_total on an undecided request would flip + // it to partially_paid while bypassing the PATCH lifecycle rule that + // partially_paid requires decided_total: the beslut would never be + // recorded and later PATCH calls would be blocked by ALLOWED_TRANSITIONS. + if (amount < Number(payoutRequest.requested_total) && payoutRequest.decided_total == null) { + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_SETTLE_INVALID_STATE', + details: { + status: payoutRequest.status, + reason: + 'Delutbetalning kräver att Skatteverkets beslut registreras först (decided_total via PATCH).', + }, + } + } + + // Never book more than Skatteverket can owe on this begäran: a larger bank + // row (a moms/skattekonto refund, two begäran in one transfer) would drive + // 1513 into a credit balance and rewrite decided_total to the bank amount. + // The user books such a row another way; this path stays exact. + const expectedAmount = roundOre(Number(payoutRequest.decided_total ?? payoutRequest.requested_total)) + if (amount > expectedAmount + 0.005) { + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS', + details: { amount, expected_amount: expectedAmount, status: payoutRequest.status }, + } + } + + // The voucher is the accounting record: engine failure must block. + let journalEntryId: string + try { + const entry = await createRotRutPayoutEntry(supabase, companyId, userId, { + requestId: payoutRequest.id, + requestName: payoutRequest.name, + deductionType: payoutRequest.deduction_type, + paymentDate: params.paymentDate, + amount, + bankAccount: params.bankAccount, + }) + journalEntryId = entry.id + } catch (engineError) { + return { ok: false, kind: 'error', error: engineError, stage: 'book' } + } + + const fullyPaid = amount >= Number(payoutRequest.requested_total) + const update: Record = { + settlement_journal_entry_id: journalEntryId, + status: fullyPaid ? 'paid' : 'partially_paid', + decided_total: payoutRequest.decided_total ?? amount, + } + if (!payoutRequest.decided_at) { + update.decided_at = new Date().toISOString() + } + + // CAS on settlement_journal_entry_id IS NULL: two concurrent settles (two + // same-amount bank rows, or a headless call racing a match) must not both + // attach and credit 1513 twice. The loser's voucher already exists + // (immutable per BFL): say so loudly rather than overwrite the winner. + const { data: updated, error: updateError } = await supabase + .from('rot_rut_payout_requests') + .update(update) + .eq('company_id', companyId) + .eq('id', params.requestId) + .is('settlement_journal_entry_id', null) + .select( + 'id, name, deduction_type, status, requested_total, decided_total, decided_at, settlement_journal_entry_id', + ) + .maybeSingle() + + if (updateError) { + // The voucher exists (immutable per BFL) but the request row didn't + // absorb the link: surface loudly, do NOT try to unbook. + log.error('rot/rut payout entry booked but request update failed', updateError, { + journalEntryId, + payoutRequestId: params.requestId, + }) + return { ok: false, kind: 'error', error: updateError, stage: 'update' } + } + if (!updated) { + log.error('rot/rut payout entry booked but request was settled concurrently', undefined, { + journalEntryId, + payoutRequestId: params.requestId, + }) + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_SETTLE_RACE', + details: { journal_entry_id: journalEntryId, request_id: params.requestId }, + } + } + + if (params.transactionId) { + // Optimistic lock on the pointer the route read: null for a free row, or + // the stale id of a reversed entry (issue #988) that the route judged not + // live. A concurrent booking between that read and this write changes + // the pointer, so the write matches 0 rows instead of silently + // overwriting it (same CAS contract as link-journal-entry.ts). + const previousJournalEntryId = params.previousJournalEntryId ?? null + const txUpdate = supabase + .from('transactions') + .update({ + journal_entry_id: journalEntryId, + is_business: true, + category: 'income_other', + potential_invoice_id: null, + potential_supplier_invoice_id: null, + potential_rot_rut_payout_request_id: null, + // The match supersedes any prior reconciliation link (mirrors + // match-invoice): a literal null keeps the phantom-column scanner + // able to verify the column set. + reconciliation_method: null, + }) + .eq('id', params.transactionId) + .eq('company_id', companyId) + const { data: linkedRows, error: linkError } = await (previousJournalEntryId === null + ? txUpdate.is('journal_entry_id', null) + : txUpdate.eq('journal_entry_id', previousJournalEntryId) + ).select('id') + + if (linkError || !linkedRows || linkedRows.length === 0) { + // Voucher booked and request settled, but the bank row is not linked: + // the user can still attach it via "Matcha mot befintlig verifikation". + // Say exactly that instead of pretending the match went through. + log.error('rot/rut payout settled but transaction link failed', linkError ?? undefined, { + journalEntryId, + payoutRequestId: params.requestId, + transactionId: params.transactionId, + reason: linkError?.message ?? 'optimistic lock returned 0 rows', + }) + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_MATCH_TX_LINK_FAILED', + details: { journal_entry_id: journalEntryId, request_id: params.requestId }, + } + } + + // An utbetalningsbesked pinned on the bank row becomes the voucher's + // underlag (BFL 5 kap 6 §), as every other booking path does. + await propagateUnderlagForBookedTransaction( + supabase, + companyId, + params.transactionId, + journalEntryId, + ) + + await logMatchEvent(supabase, userId, params.transactionId, 'matched', { + matchConfidence: 1.0, + matchMethod: 'rot_rut_payout_manual_confirm', + newState: { + journal_entry_id: journalEntryId, + rot_rut_payout_request_id: params.requestId, + request_status: update.status, + amount, + }, + }) + } + + if (fullyPaid) { + const { data: items, error: itemsFetchError } = await supabase + .from('rot_rut_payout_request_items') + .select('id, requested_amount') + .eq('request_id', params.requestId) + if (itemsFetchError) { + log.warn('failed to fetch items for decided_amount mirror', { + payoutRequestId: params.requestId, + message: itemsFetchError.message, + }) + } + for (const item of items ?? []) { + const { error: mirrorError } = await supabase + .from('rot_rut_payout_request_items') + .update({ decided_amount: item.requested_amount }) + .eq('id', item.id) + if (mirrorError) { + log.warn('failed to mirror decided_amount onto item', { + itemId: item.id, + message: mirrorError.message, + }) + } + } + } + + // The request is settled: every OTHER bank row still hinting at it is a dead + // suggestion. This row's own hint was cleared by the link update above. + await clearSettledInvoiceSuggestions( + supabase, + companyId, + 'rot_rut_payout_request', + params.requestId, + { exceptTransactionId: params.transactionId ?? null }, + ) + + log.info('rot/rut payout settled', { + userId, + payoutRequestId: params.requestId, + journalEntryId, + amount, + fullyPaid, + transactionId: params.transactionId ?? null, + }) + + return { + ok: true, + request: updated as SettledRotRutPayoutRequest, + journalEntryId, + amount, + fullyPaid, + } +} diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index b609a393..7ce748d4 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -1852,13 +1852,18 @@ export async function autoReconcileTransactionForLinkedVoucher( // Tag the transaction with the (supplier) invoice for traceability + parity // with the transactions-side match. is_business is already set by manualLink, // so the row has already dropped out of the inbox regardless of this update. - const tag: Record = { potential_invoice_id: null } + const tag: Record = { + potential_invoice_id: null, + potential_rot_rut_payout_request_id: null, + } if (options.invoiceId) tag.invoice_id = options.invoiceId if (options.supplierInvoiceId) { tag.supplier_invoice_id = options.supplierInvoiceId tag.potential_supplier_invoice_id = null } - if (Object.keys(tag).length > 1) { + // Two hint clears are always present; only an actual invoice tag warrants + // the extra write (the row already left the inbox via is_business). + if (Object.keys(tag).length > 2) { await supabase .from('transactions') .update(tag) diff --git a/lib/transactions/__tests__/ingest.test.ts b/lib/transactions/__tests__/ingest.test.ts index 51c62371..6500fbb7 100644 --- a/lib/transactions/__tests__/ingest.test.ts +++ b/lib/transactions/__tests__/ingest.test.ts @@ -34,6 +34,16 @@ vi.mock('@/lib/currency/riksbanken', () => ({ fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args), })) +// Mocked so the open-begäran pool consumes no slot in the queued Supabase +// mock: every existing test enqueues results in the pre-fetch order. +const mockLoadOpenRotRutPayoutRequests = vi.fn() +vi.mock('@/lib/invoices/rot-rut-payout-candidates', () => ({ + loadOpenRotRutPayoutRequests: (...args: unknown[]) => mockLoadOpenRotRutPayoutRequests(...args), +})) +// Default: no open begäran. vi.clearAllMocks keeps implementations, so this +// survives beforeEach; tests that need a pool override it. +mockLoadOpenRotRutPayoutRequests.mockResolvedValue([]) + // --------------------------------------------------------------------------- // Queue-based Supabase mock // --------------------------------------------------------------------------- @@ -1757,6 +1767,51 @@ describe('ingestTransactions', () => { ) }) + // ----------------------------------------------------------------------- + // 4a. Skatteverkets ROT/RUT payout: an income row equal to an open begäran + // gets potential_rot_rut_payout_request_id (a suggestion, never a hard + // link) and skips auto-categorisation, exactly like an invoice hint. + // ----------------------------------------------------------------------- + it('suggests the open ROT/RUT payout request for a matching Skatteverket income row', async () => { + const { supabase, enqueue, updates } = createQueueMockSupabase() + const raw = makeRaw({ amount: 3000, description: 'Skatteverket utbetalning' }) + const inserted = makeTransaction({ + id: 'tx-skv', + amount: 3000, + currency: 'SEK', + description: 'Skatteverket utbetalning', + external_id: raw.external_id, + }) + mockLoadOpenRotRutPayoutRequests.mockResolvedValueOnce([ + { + id: 'rr-1', + name: 'ROT 2026-07', + deduction_type: 'rot', + status: 'submitted', + requested_total: 3000, + decided_total: null, + settlement_journal_entry_id: null, + }, + ]) + mockGetBestInvoiceMatch.mockResolvedValue(null) + + enqueue({ data: [], error: null }) // booked map + enqueue({ data: [], error: null }) // unbooked bank-synced map + enqueue({ data: [], error: null }) // supplier invoices pool + enqueue({ data: [], error: null }) // external_id dedup + enqueue({ data: inserted, error: null }) // insert + enqueue({ data: null, error: null }) // suggestion update + + const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw]) + + expect(result.imported).toBe(1) + expect(result.auto_matched_invoices).toBe(1) + const txUpdates = (updates['transactions'] ?? []) as Record[] + expect(txUpdates).toEqual([{ potential_rot_rut_payout_request_id: 'rr-1' }]) + // The hint short-circuits the mapping engine: no auto-booked voucher. + expect(mockEvaluateMappingRules).not.toHaveBeenCalled() + }) + // ----------------------------------------------------------------------- // 4b. Supplier-invoice match at sync is ALWAYS a suggestion, never a hard // link. Regression: a high-confidence hit used to set diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts index a9baf5c2..041dad84 100644 --- a/lib/transactions/ingest.ts +++ b/lib/transactions/ingest.ts @@ -4,6 +4,11 @@ import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-ent import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching' import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching' +import { + findRotRutPayoutMatch, + type RotRutPayoutRequestCandidate, +} from '@/lib/invoices/rot-rut-payout-matching' +import { loadOpenRotRutPayoutRequests } from '@/lib/invoices/rot-rut-payout-candidates' import { fetchExchangeRate } from '@/lib/currency/riksbanken' import { logMatchEvent } from '@/lib/invoices/match-log' import { fetchAllRows } from '@/lib/supabase/fetch-all' @@ -358,6 +363,7 @@ export async function ingestTransactions( // When rawInsertOnly is set (viewer imports), skip pre-fetching supplier // invoices and exchange rates: they are not used. let unpaidSupplierInvoices: SupplierInvoice[] = [] + let openRotRutPayoutRequests: RotRutPayoutRequestCandidate[] = [] // Keyed by `${currency}|${date}` so each non-SEK transaction gets the // rate that was valid on its own transaction date, not the import date. const exchangeRatesByDate = new Map() @@ -377,6 +383,11 @@ export async function ingestTransactions( } catch { // Non-critical: supplier invoice matching will be skipped } + // Pre-fetch open ROT/RUT payout requests for income matching (non-critical). + // Skatteverket's payout is the one income row a paid ROT/RUT invoice can no + // longer explain (remaining_amount is net of the deduction), so the begäran + // is the candidate. Small table: a company has a handful of open requests. + openRotRutPayoutRequests = await loadOpenRotRutPayoutRequests(supabase, companyId) } // Pre-fetch exchange rates for each unique (currency, date) pair in the @@ -581,6 +592,7 @@ export async function ingestTransactions( // to prevent suggesting the same invoice for multiple transactions const matchedInvoiceIds = new Set() const matchedSupplierInvoiceIds = new Set() + const matchedRotRutRequestIds = new Set() for (const raw of rawTransactions) { // Normalize the source title once. Guarantees a non-empty, Swedish-first @@ -1079,6 +1091,44 @@ export async function ingestTransactions( } } + // 3c. For income transactions with no invoice hint, try ROT/RUT payout + // matching: Skatteverket's lump sum for an open begäran. Always a + // suggestion (potential_rot_rut_payout_request_id), never a hard link: + // the match route books the 19xx/1513 voucher when it confirms. + if (newTransaction.amount > 0 && openRotRutPayoutRequests.length > 0) { + try { + const match = findRotRutPayoutMatch(newTransaction as Transaction, openRotRutPayoutRequests) + if (match && !matchedRotRutRequestIds.has(match.request.id)) { + // supabase-js resolves a failed update with { error }: a hint that + // never persisted must not drain the pool or count as a match. + const { error: hintError } = await supabase + .from('transactions') + .update({ potential_rot_rut_payout_request_id: match.request.id }) + .eq('id', newTransaction.id) + if (hintError) throw hintError + + logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', { + matchConfidence: match.confidence, + matchMethod: match.matchMethod, + newState: { rot_rut_payout_request_id: match.request.id }, + }) + + // One payout per begäran: drain the pool so a second row of the same + // amount can't claim it, and skip the mapping engine (an + // auto-categorised 19xx/3xxx voucher would collide with the 1513 + // clearing the match books). + matchedRotRutRequestIds.add(match.request.id) + openRotRutPayoutRequests = openRotRutPayoutRequests.filter( + (req) => req.id !== match.request.id, + ) + result.auto_matched_invoices++ + continue + } + } catch { + // Non-critical: continue processing + } + } + // 4. Evaluate mapping rules for auto-categorization // Production-disabled: auto-booking only runs in local dev (and tests). // Users must explicitly book each transaction on the deployed app. diff --git a/lib/transactions/link-journal-entry.ts b/lib/transactions/link-journal-entry.ts index 281007dc..81eace02 100644 --- a/lib/transactions/link-journal-entry.ts +++ b/lib/transactions/link-journal-entry.ts @@ -140,7 +140,7 @@ export async function linkTransactionToJournalEntry( const { data: transactionRow, error: fetchTxError } = await supabase .from('transactions') .select( - 'id, date, amount, currency, exchange_rate, journal_entry_id, invoice_id, is_business, potential_invoice_id, potential_supplier_invoice_id, transaction_voucher_links(journal_entry_id, role)' + 'id, date, amount, currency, exchange_rate, journal_entry_id, invoice_id, is_business, potential_invoice_id, potential_supplier_invoice_id, potential_rot_rut_payout_request_id, transaction_voucher_links(journal_entry_id, role)' ) .eq('id', transactionId) .eq('company_id', companyId) @@ -287,6 +287,7 @@ export async function linkTransactionToJournalEntry( invoice_id: transaction.invoice_id, potential_invoice_id: transaction.potential_invoice_id, potential_supplier_invoice_id: transaction.potential_supplier_invoice_id, + potential_rot_rut_payout_request_id: transaction.potential_rot_rut_payout_request_id ?? null, is_business: transaction.is_business, } @@ -302,6 +303,7 @@ export async function linkTransactionToJournalEntry( invoice_id: invoiceId ?? null, potential_invoice_id: null, potential_supplier_invoice_id: null, + potential_rot_rut_payout_request_id: null, is_business: true, }) .eq('id', transactionId) @@ -463,6 +465,7 @@ export async function linkTransactionToJournalEntry( ...transaction, journal_entry_id: journalEntryId, invoice_id: invoiceId, + potential_rot_rut_payout_request_id: null, potential_invoice_id: null, potential_supplier_invoice_id: null, is_business: true, diff --git a/lib/worklist/__tests__/categories.test.ts b/lib/worklist/__tests__/categories.test.ts index 89363cfa..ec8fbf66 100644 --- a/lib/worklist/__tests__/categories.test.ts +++ b/lib/worklist/__tests__/categories.test.ts @@ -212,6 +212,45 @@ describe('countSuggestedMatches', () => { }) describe('listSuggestedMatches', () => { + it('maps a ROT/RUT payout hint to a confirmable row pointing at the begäran', async () => { + enqueue({ + data: [ + { + id: 'tx-skv', + date: '2026-07-10', + description: 'Skatteverket', + amount: 3000, + currency: 'SEK', + potential_invoice_id: null, + potential_supplier_invoice_id: null, + potential_rot_rut_payout_request_id: 'rr-1', + }, + ], + }) + // Only the payout lookup runs: the invoice / supplier id lists are empty. + enqueue({ + data: [{ id: 'rr-1', name: 'ROT 2026-07', requested_total: '3000.00', decided_total: null }], + }) + + const matches = await listSuggestedMatches(supabase, COMPANY) + expect(matches).toEqual([ + { + transaction_id: 'tx-skv', + transaction_date: '2026-07-10', + transaction_description: 'Skatteverket', + transaction_amount: 3000, + transaction_currency: 'SEK', + kind: 'rot_rut_payout', + candidate_id: 'rr-1', + candidate_number: 'ROT 2026-07', + counterparty_name: 'Skatteverket', + candidate_total: 3000, + }, + ]) + const lookup = findCall('rot_rut_payout_requests', 'is') + expect(lookup).toEqual(['settlement_journal_entry_id', null]) + }) + it('maps invoice and supplier-invoice hints to confirmable rows', async () => { enqueue({ data: [ diff --git a/lib/worklist/categories.ts b/lib/worklist/categories.ts index a1284af7..2573fcbe 100644 --- a/lib/worklist/categories.ts +++ b/lib/worklist/categories.ts @@ -8,6 +8,7 @@ * down the dashboard layout or the home page. */ +import { OPEN_ROT_RUT_PAYOUT_STATUSES } from '@/lib/invoices/rot-rut-payout-matching' import type { SupabaseClient } from '@supabase/supabase-js' import { createLogger } from '@/lib/logger' import { @@ -146,7 +147,7 @@ export async function countInboxDocuments( /** Shared predicate for transactions carrying a match hint. */ const SUGGESTED_MATCH_OR = - 'potential_invoice_id.not.is.null,potential_supplier_invoice_id.not.is.null' + 'potential_invoice_id.not.is.null,potential_supplier_invoice_id.not.is.null,potential_rot_rut_payout_request_id.not.is.null' /** * Cap on the hint scan behind countSuggestedMatches; clamps like @@ -285,6 +286,14 @@ interface SuggestedMatchTxRow { currency: string | null potential_invoice_id: string | null potential_supplier_invoice_id: string | null + potential_rot_rut_payout_request_id?: string | null +} + +type PayoutCandidateRow = { + id: string + name: string + requested_total: number | string + decided_total: number | string | null } type CandidateRow = { @@ -345,7 +354,7 @@ export async function listSuggestedMatches( const { data: txRows, error } = await supabase .from('transactions') .select( - 'id, date, description, amount, currency, potential_invoice_id, potential_supplier_invoice_id', + 'id, date, description, amount, currency, potential_invoice_id, potential_supplier_invoice_id, potential_rot_rut_payout_request_id', ) .eq('company_id', companyId) .is('is_business', null) @@ -368,7 +377,13 @@ export async function listSuggestedMatches( ...new Set(txs.map((t) => t.potential_supplier_invoice_id).filter((x): x is string => !!x)), ] - const [invoiceRes, supplierRes] = await Promise.all([ + const payoutRequestIds = [ + ...new Set( + txs.map((t) => t.potential_rot_rut_payout_request_id).filter((x): x is string => !!x), + ), + ] + + const [invoiceRes, supplierRes, payoutRes] = await Promise.all([ fetchCandidatesChunked(invoiceIds, (chunk) => supabase .from('invoices') @@ -387,12 +402,23 @@ export async function listSuggestedMatches( .in('status', [...MATCHABLE_SUPPLIER_INVOICE_STATUSES]) .gt('remaining_amount', 0), ), + // Open, unsettled begäran only: a settled request must not render a + // one-click confirm that the route can only answer with INVALID_STATE. + fetchCandidatesChunked(payoutRequestIds, (chunk) => + supabase + .from('rot_rut_payout_requests') + .select('id, name, requested_total, decided_total') + .eq('company_id', companyId) + .in('id', chunk) + .in('status', [...OPEN_ROT_RUT_PAYOUT_STATUSES]) + .is('settlement_journal_entry_id', null), + ), ]) // A failed candidate lookup must not pass for "nothing is matchable": that // would render an empty list and, through countSuggestedMatches, a silent // zero badge. Log it (with companyId) and bail, same as the tx query above. - const candidateError = invoiceRes.error ?? supplierRes.error + const candidateError = invoiceRes.error ?? supplierRes.error ?? payoutRes.error if (candidateError) { log.error('worklist listSuggestedMatches candidate lookup failed', { companyId, @@ -404,6 +430,10 @@ export async function listSuggestedMatches( const invoiceById = new Map(invoiceRes.rows.map((r) => [r.id, r])) const supplierById = new Map(supplierRes.rows.map((r) => [r.id, r])) + const payoutById = new Map( + (payoutRes.rows as unknown as PayoutCandidateRow[]).map((r) => [r.id, r] as const), + ) + const matches: SuggestedMatch[] = [] for (const tx of txs) { const base = { @@ -441,6 +471,20 @@ export async function listSuggestedMatches( counterparty_name: supplierInvoice.supplier?.name ?? null, candidate_total: supplierInvoice.total ?? null, }) + continue + } + const payoutRequest = tx.potential_rot_rut_payout_request_id + ? payoutById.get(tx.potential_rot_rut_payout_request_id) + : undefined + if (payoutRequest) { + matches.push({ + ...base, + kind: 'rot_rut_payout', + candidate_id: payoutRequest.id, + candidate_number: payoutRequest.name, + counterparty_name: 'Skatteverket', + candidate_total: Number(payoutRequest.decided_total ?? payoutRequest.requested_total), + }) } // Hint pointing at a deleted, foreign or already-settled candidate → drop // the row rather than render an unconfirmable suggestion. diff --git a/lib/worklist/types.ts b/lib/worklist/types.ts index 44cf80e2..ef52e540 100644 --- a/lib/worklist/types.ts +++ b/lib/worklist/types.ts @@ -124,8 +124,12 @@ export interface SuggestedMatch { transaction_description: string transaction_amount: number transaction_currency: string - /** Which match endpoint confirms it: match-invoice vs match-supplier-invoice. */ - kind: 'invoice' | 'supplier_invoice' + /** + * Which match endpoint confirms it: match-invoice, match-supplier-invoice, + * or match-rot-rut-payout (Skatteverkets utbetalning for an open begäran; + * candidate_number is then the request name). + */ + kind: 'invoice' | 'supplier_invoice' | 'rot_rut_payout' candidate_id: string candidate_number: string | null counterparty_name: string | null diff --git a/messages/en.json b/messages/en.json index 600e9b1e..76a3fe3e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2903,6 +2903,7 @@ "skv_counterpart_body": "There is a skattekonto event on {date} that matches: post this voucher first, then link the skattekonto row to the same voucher instead of posting it twice.", "match_invoice_btn": "Match invoice {number}", "match_supplier_invoice_btn": "Match supplier invoice {number}", + "match_rot_rut_payout_btn": "Match ROT/RUT payout {name}", "match_voucher_btn": "Match to existing voucher", "attach_document_btn": "Match to document", "more_actions_aria": "More actions", @@ -3147,8 +3148,44 @@ "confirming": "Confirming...", "confirm_match": "Confirm match" }, + "tx_rot_rut_match": { + "title": "Confirm ROT/RUT payout", + "description": "Book this payment from Skatteverket against the payout request? The receivable on account 1513 is cleared and the transaction is linked to the voucher.", + "description_blocked": "The selected request can no longer be matched to the transaction.", + "transaction_label": "Transaction", + "request_label": "Payout request", + "request_name": "{type} {name}", + "requested_total": "Requested: {amount}", + "decided_total": "Decided: {amount}", + "invoices_title": "Invoices included", + "invoice_row": "Invoice {number}", + "amounts_match": "The amount matches the request", + "amounts_differ": "The amount differs from the request", + "amount_diff": "Difference: {amount}", + "over_payout_blocked": "The amount exceeds what Skatteverket can pay out for this request, so it cannot be booked here. Book the transaction another way, or split it if it covers several requests.", + "partial_requires_beslut": "A partial payout requires Skatteverket's decision (approved amount) to be recorded on the request first. Record the decision and try again, or book the transaction manually against account 1513.", + "partial_with_beslut_note": "The payout is lower than the requested amount: the request is marked partially paid and the remainder stays on account 1513.", + "target_settled_title": "The request is already paid out", + "target_settled_description": "The payout for this request is already booked. Close the dialog and book the transaction another way.", + "target_not_open_title": "The request is not open", + "target_not_open_description": "The request was cancelled or rejected and cannot be matched. Close the dialog and book the transaction another way.", + "booking_title": "Booking", + "booking_debit": "Debit", + "booking_credit": "Credit", + "booking_bank_line": "Bank account (the transaction's cash account)", + "booking_receivable_line": "1513 Receivable, split invoice (ROT/RUT)", + "on_confirm_title": "On confirmation:", + "on_confirm_link": "The transaction is linked to the voucher", + "on_confirm_request": "The request is marked as paid out", + "on_confirm_voucher": "A journal entry is created automatically", + "cancel": "Cancel", + "confirming": "Confirming...", + "confirm": "Confirm match" + }, "tx_invoice_picker": { "loading": "Loading invoices...", + "rot_rut_section_title": "ROT/RUT payouts from Skatteverket", + "rot_rut_request_meta": "{type} · {count} invoices", "empty": "No open invoices to match against.", "search_placeholder": "Search invoice number or customer...", "no_number": "(no number)", @@ -5904,6 +5941,9 @@ "skv_reconnect_body": "Tax account transactions are not fetched until you reconnect with BankID and approve all permissions.", "skv_reconnect_cta": "Reconnect", "dialog_match_invoice": "Match with invoice", + "rot_rut_payout_matched_title": "ROT/RUT payout matched", + "rot_rut_payout_matched_description": "The payout from Skatteverket was booked against request {name}", + "rot_rut_payout_match_failed_title": "Could not match the ROT/RUT payout", "dialog_add_transaction": "Add transaction", "dialog_match_supplier_invoice": "Match with supplier invoice?", "dialog_match_customer_invoice": "Match with customer invoice?", @@ -6615,6 +6655,7 @@ "suggested_title": "Suggested matches", "suggested_kind_invoice": "Invoice", "suggested_kind_supplier_invoice": "Supplier invoice", + "suggested_kind_rot_rut_payout": "ROT/RUT payout", "suggested_confirm": "Confirm", "suggested_view": "View transaction", "suggested_confirmed_toast": "Match recorded", diff --git a/messages/sv.json b/messages/sv.json index af94a61a..3abf81dc 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2903,6 +2903,7 @@ "skv_counterpart_body": "Det finns en skattekonto-händelse den {date} som matchar: bokför detta verifikat först, koppla sedan skattekonto-raden mot samma verifikat istället för att bokföra två gånger.", "match_invoice_btn": "Matcha Faktura {number}", "match_supplier_invoice_btn": "Matcha Leverantörsfaktura {number}", + "match_rot_rut_payout_btn": "Matcha ROT/RUT-utbetalning {name}", "match_voucher_btn": "Matcha mot befintlig verifikation", "attach_document_btn": "Matcha mot underlag", "more_actions_aria": "Fler åtgärder", @@ -3147,8 +3148,44 @@ "confirming": "Bekräftar...", "confirm_match": "Bekräfta matchning" }, + "tx_rot_rut_match": { + "title": "Bekräfta ROT/RUT-utbetalning", + "description": "Vill du bokföra denna inbetalning från Skatteverket mot begäran om utbetalning? Fordran på konto 1513 regleras och transaktionen kopplas till verifikationen.", + "description_blocked": "Den valda begäran kan inte längre matchas mot transaktionen.", + "transaction_label": "Transaktion", + "request_label": "Begäran om utbetalning", + "request_name": "{type} {name}", + "requested_total": "Begärt: {amount}", + "decided_total": "Beslutat: {amount}", + "invoices_title": "Fakturor som ingår", + "invoice_row": "Faktura {number}", + "amounts_match": "Beloppet stämmer med begäran", + "amounts_differ": "Beloppet skiljer sig från begäran", + "amount_diff": "Differens: {amount}", + "over_payout_blocked": "Beloppet är större än vad Skatteverket kan betala ut för denna begäran, så det kan inte bokföras här. Bokför transaktionen på annat sätt, eller dela upp den om den täcker flera begäran.", + "partial_requires_beslut": "En delutbetalning kräver att Skatteverkets beslut registreras på begäran först (godkänt belopp). Registrera beslutet och försök igen, eller bokför transaktionen manuellt mot konto 1513.", + "partial_with_beslut_note": "Utbetalningen är lägre än begärt belopp: begäran markeras som delvis utbetald och resten ligger kvar på konto 1513.", + "target_settled_title": "Begäran är redan utbetald", + "target_settled_description": "Utbetalningen för denna begäran är redan bokförd. Stäng dialogen och bokför transaktionen på annat sätt.", + "target_not_open_title": "Begäran är inte öppen", + "target_not_open_description": "Begäran har avbrutits eller avslagits och kan inte matchas. Stäng dialogen och bokför transaktionen på annat sätt.", + "booking_title": "Bokföring", + "booking_debit": "Debet", + "booking_credit": "Kredit", + "booking_bank_line": "Bankkonto (transaktionens kassakonto)", + "booking_receivable_line": "1513 Kundfordringar delad faktura", + "on_confirm_title": "Vid bekräftelse:", + "on_confirm_link": "Transaktionen kopplas till verifikationen", + "on_confirm_request": "Begäran markeras som utbetald", + "on_confirm_voucher": "Bokföringsverifikation skapas automatiskt", + "cancel": "Avbryt", + "confirming": "Bekräftar...", + "confirm": "Bekräfta matchning" + }, "tx_invoice_picker": { "loading": "Laddar fakturor...", + "rot_rut_section_title": "ROT/RUT-utbetalningar från Skatteverket", + "rot_rut_request_meta": "{type} · {count} fakturor", "empty": "Inga öppna fakturor att matcha mot.", "search_placeholder": "Sök fakturanummer eller kund...", "no_number": "(utan nummer)", @@ -5904,6 +5941,9 @@ "skv_reconnect_body": "Skattekontots transaktioner hämtas inte förrän du anslutit igen med BankID och godkänt alla behörigheter.", "skv_reconnect_cta": "Anslut igen", "dialog_match_invoice": "Matcha med faktura", + "rot_rut_payout_matched_title": "ROT/RUT-utbetalning matchad", + "rot_rut_payout_matched_description": "Utbetalningen från Skatteverket bokfördes mot begäran {name}", + "rot_rut_payout_match_failed_title": "Kunde inte matcha ROT/RUT-utbetalningen", "dialog_add_transaction": "Lägg till transaktion", "dialog_match_supplier_invoice": "Matcha mot leverantörsfaktura?", "dialog_match_customer_invoice": "Matcha mot kundfaktura?", @@ -6615,6 +6655,7 @@ "suggested_title": "Föreslagna matchningar", "suggested_kind_invoice": "Faktura", "suggested_kind_supplier_invoice": "Leverantörsfaktura", + "suggested_kind_rot_rut_payout": "ROT/RUT-utbetalning", "suggested_confirm": "Bekräfta", "suggested_view": "Visa transaktionen", "suggested_confirmed_toast": "Matchning bokförd", diff --git a/supabase/migrations/20260904020000_rot_rut_payout_transaction_match.sql b/supabase/migrations/20260904020000_rot_rut_payout_transaction_match.sql new file mode 100644 index 00000000..51d843ad --- /dev/null +++ b/supabase/migrations/20260904020000_rot_rut_payout_transaction_match.sql @@ -0,0 +1,17 @@ +-- Migration: rot_rut_payout_transaction_match +-- Adds potential_rot_rut_payout_request_id to transactions: a match SUGGESTION +-- (never a hard link) pointing at the open ROT/RUT begäran whose payout the +-- bank row appears to be. Mirrors potential_supplier_invoice_id +-- (20260225100248). The confirmed link is transactions.journal_entry_id = +-- rot_rut_payout_requests.settlement_journal_entry_id, written by the +-- match-rot-rut-payout route; this column only carries the hint until then. + +ALTER TABLE public.transactions + ADD COLUMN IF NOT EXISTS potential_rot_rut_payout_request_id UUID + REFERENCES public.rot_rut_payout_requests(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_transactions_potential_rot_rut_payout_request + ON public.transactions(potential_rot_rut_payout_request_id) + WHERE potential_rot_rut_payout_request_id IS NOT NULL; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260904021000_rot_rut_payout_voucher_unique.sql b/supabase/migrations/20260904021000_rot_rut_payout_voucher_unique.sql new file mode 100644 index 00000000..0ba5e013 --- /dev/null +++ b/supabase/migrations/20260904021000_rot_rut_payout_voucher_unique.sql @@ -0,0 +1,20 @@ +-- One live settlement voucher per ROT/RUT begäran, enforced at the journal. +-- +-- Two concurrent settles (two same-amount bank rows, or a headless call racing +-- a bank-row match) can both pass the application's "not yet settled" read and +-- both book debit 19xx / credit 1513 before the request row's compare-and-set +-- picks a winner. The loser's voucher would stand (posted entries are +-- immutable) and 1513 would be credited twice for one payout. This index makes +-- the second entry fail at insert, before it can ever be committed. +-- +-- draft is included on purpose: the engine inserts a draft and commits it in a +-- second step, so a loser must fail at the draft insert and leave nothing +-- behind. Reversed/cancelled entries fall outside the predicate, so a storno +-- of a settlement never blocks a later re-settle. +-- pg-test: tests/pg/rot-rut-payout-voucher-unique.pg.test.ts + +CREATE UNIQUE INDEX IF NOT EXISTS journal_entries_rot_rut_payout_live_unique + ON public.journal_entries (company_id, source_id) + WHERE source_type = 'rot_rut_payout' + AND source_id IS NOT NULL + AND status IN ('draft', 'posted'); diff --git a/tests/pg/rot-rut-payout-voucher-unique.pg.test.ts b/tests/pg/rot-rut-payout-voucher-unique.pg.test.ts new file mode 100644 index 00000000..f1492153 --- /dev/null +++ b/tests/pg/rot-rut-payout-voucher-unique.pg.test.ts @@ -0,0 +1,59 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { insertPostedJournalEntry, seedCompany } from './fixtures' + +describe('rot/rut payout settlement voucher uniqueness', () => { + it('allows exactly one live settlement voucher per begäran', async () => { + const seeded = await seedCompany() + const requestId = randomUUID() + const common = { + userId: seeded.userId, + companyId: seeded.companyId, + fiscalPeriodId: seeded.fiscalPeriodId, + entryDate: '2026-07-10', + description: 'Utbetalning ROT-avdrag från Skatteverket (ROT 2026-07)', + sourceType: 'rot_rut_payout', + sourceId: requestId, + lines: [ + { accountNumber: '1930', debitAmount: 3000, creditAmount: 0 }, + { accountNumber: '1513', debitAmount: 0, creditAmount: 3000 }, + ], + } + + const results = await Promise.allSettled([ + insertPostedJournalEntry({ ...common, voucherNumber: 31 }), + insertPostedJournalEntry({ ...common, voucherNumber: 32 }), + ]) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + const rejected = results.find((result) => result.status === 'rejected') + expect(String((rejected as PromiseRejectedResult).reason)).toMatch( + /journal_entries_rot_rut_payout_live_unique/, + ) + }) + + it('does not block a second begäran or other source types', async () => { + const seeded = await seedCompany() + const common = { + userId: seeded.userId, + companyId: seeded.companyId, + fiscalPeriodId: seeded.fiscalPeriodId, + entryDate: '2026-07-10', + sourceType: 'rot_rut_payout', + } + await insertPostedJournalEntry({ ...common, sourceId: randomUUID(), voucherNumber: 41 }) + await expect( + insertPostedJournalEntry({ ...common, sourceId: randomUUID(), voucherNumber: 42 }), + ).resolves.toBeTruthy() + // Same source_id under another source_type is outside the predicate. + const shared = randomUUID() + await insertPostedJournalEntry({ ...common, sourceId: shared, voucherNumber: 43 }) + await expect( + insertPostedJournalEntry({ + ...common, + sourceType: 'manual', + sourceId: shared, + voucherNumber: 44, + }), + ).resolves.toBeTruthy() + }) +}) diff --git a/types/index.ts b/types/index.ts index bbad8707..69acedb6 100644 --- a/types/index.ts +++ b/types/index.ts @@ -783,6 +783,11 @@ export interface Transaction { // Potential supplier invoice match (suggested, not confirmed) potential_supplier_invoice_id: string | null + // Potential ROT/RUT payout-request match (suggested, not confirmed): the + // open begäran whose Skatteverket payout this income row appears to be. + // Optional: rows fetched before migration 20260904020000 lack the column. + potential_rot_rut_payout_request_id?: string | null + // Potential journal-entry match (suggested by the reconciliation sweep, not // confirmed). All three set together, or all null; cleared by DB triggers // when the row is booked/ignored or the entry is consumed/reversed.