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

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

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

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

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

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

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

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

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

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

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

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

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-04 14:33:41 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 82859d01db
commit 50b6299699
34 changed files with 2350 additions and 147 deletions
+39
View File
@@ -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: [
+48 -4
View File
@@ -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<string, CandidateRow>(invoiceRes.rows.map((r) => [r.id, r]))
const supplierById = new Map<string, CandidateRow>(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.
+6 -2
View File
@@ -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