feat(reconciliation): suggest and book Skatteverkets bundled ROT/RUT payout against several begäran (#2360)
* feat(reconciliation): suggest and book Skatteverkets bundled ROT/RUT payout against several begäran Skatteverket decides per begäran but pays everything it decided that day in one transfer, so the bank row often equals no single open begäran and the 1:1 matcher from #2271 stayed silent; the settle service then refused the amount and the dialog told the user to split the transaction by hand. The candidate is now the exact covering set of 1..4 open begäran whose expected payouts sum to the row (lib/invoices/rot-rut-payout-set-matching.ts, over the existing findExactCoveringSet, ambiguity-refusing). It is computed at read time from the open pool (inbox page, worklist, ingest), no hint column. Confirming books ONE voucher (debit 19xx, one 1513 credit per begäran) through the same writer, marks every begäran paid and links the row once; a bundle is always booked at exactly the decided sums. - match-rot-rut-payout accepts request_ids (1..10) beside request_id - settleRotRutPayoutRequestSet shares the single path's tail - createRotRutPayoutSetEntry; createRotRutPayoutEntry delegates (N=1 unchanged) - inbox pill, RotRutPayoutMatchDialog, Att göra and ingest handle the set - new error code ROT_RUT_SETTLE_SET_AMOUNT; sv/en strings for the set Closes #2239 Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(invoices): PR #2360 review: partially decided begäran stays partially_paid in a bundle The bundle path booked every leg as fully paid and mirrored requested_amount onto every begäran's items, while the single path completes a begäran only when the leg covers requested_total and otherwise leaves it partially_paid for manual handling. A begäran Skatteverket decided at less than requested is a legitimate bundle member (its leg is the beslut, the exact-sum rule is unchanged), so the set path now computes fullyPaid per leg exactly like the single path, passes it to the shared attachSettlementVoucher, and mirrors only the fully paid legs; sibling hints are still cleared for every settled begäran, which carries a voucher and is no longer matchable either way. Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
4bc007cdeb
commit
0069a3f59a
@@ -14,6 +14,7 @@ import {
|
||||
countVerifikatMissingDocument,
|
||||
listExpensePayoutsDue,
|
||||
listExpensePayoutSuggestions,
|
||||
listRotRutPayoutSetSuggestions,
|
||||
listSuggestedMatches,
|
||||
} from '../categories'
|
||||
import {
|
||||
@@ -266,6 +267,75 @@ describe('listSuggestedMatches', () => {
|
||||
expect(lookup).toEqual(['settlement_journal_entry_id', null])
|
||||
})
|
||||
|
||||
it('offers a bundled Skatteverket payout (sum of several begäran) with request_ids', async () => {
|
||||
enqueue({ data: [] }) // no hinted rows
|
||||
enqueue({ data: [] }) // expense_claims: nobody is owed
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'rr-1', name: 'ROT 2026-07', deduction_type: 'rot', status: 'submitted', requested_total: '3000.00', decided_total: null, settlement_journal_entry_id: null },
|
||||
{ id: 'rr-2', name: 'RUT 2026-07', deduction_type: 'rut', status: 'submitted', requested_total: '2250.00', decided_total: null, settlement_journal_entry_id: null },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'tx-bundle',
|
||||
date: '2026-07-12',
|
||||
description: 'SKATTEVERKET',
|
||||
merchant_name: null,
|
||||
amount: 5250,
|
||||
currency: 'SEK',
|
||||
is_business: null,
|
||||
journal_entry_id: null,
|
||||
potential_rot_rut_payout_request_id: null,
|
||||
},
|
||||
{
|
||||
id: 'tx-other',
|
||||
date: '2026-07-11',
|
||||
description: 'Kund AB',
|
||||
merchant_name: null,
|
||||
amount: 4000,
|
||||
currency: 'SEK',
|
||||
is_business: null,
|
||||
journal_entry_id: null,
|
||||
potential_rot_rut_payout_request_id: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const matches = await listSuggestedMatches(supabase, COMPANY)
|
||||
expect(matches).toEqual([
|
||||
{
|
||||
transaction_id: 'tx-bundle',
|
||||
transaction_date: '2026-07-12',
|
||||
transaction_description: 'SKATTEVERKET',
|
||||
transaction_amount: 5250,
|
||||
transaction_currency: 'SEK',
|
||||
kind: 'rot_rut_payout',
|
||||
candidate_id: 'rr-1',
|
||||
candidate_number: 'ROT 2026-07 + RUT 2026-07',
|
||||
counterparty_name: 'Skatteverket',
|
||||
candidate_total: 5250,
|
||||
request_ids: ['rr-1', 'rr-2'],
|
||||
},
|
||||
])
|
||||
// The scan is bounded to the pool's amount range and to unbooked, unreviewed rows.
|
||||
const gte = findCall('transactions', 'gte')
|
||||
const lte = findCall('transactions', 'lte')
|
||||
expect(gte).toEqual(['amount', 2250])
|
||||
expect(lte).toEqual(['amount', 5250])
|
||||
})
|
||||
|
||||
it('returns no bundle suggestions for a company with fewer than two open begäran', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'rr-1', name: 'ROT 2026-07', deduction_type: 'rot', status: 'submitted', requested_total: 3000, decided_total: null, settlement_journal_entry_id: null },
|
||||
],
|
||||
})
|
||||
await expect(listRotRutPayoutSetSuggestions(supabase, COMPANY)).resolves.toEqual([])
|
||||
expect(findCalls('transactions', 'select')).toEqual([])
|
||||
})
|
||||
|
||||
it('maps invoice and supplier-invoice hints to confirmable rows', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
|
||||
+100
-1
@@ -8,9 +8,16 @@
|
||||
* down the dashboard layout or the home page.
|
||||
*/
|
||||
|
||||
import { OPEN_ROT_RUT_PAYOUT_STATUSES } from '@/lib/invoices/rot-rut-payout-matching'
|
||||
import {
|
||||
OPEN_ROT_RUT_PAYOUT_STATUSES,
|
||||
expectedRotRutPayoutAmount,
|
||||
isMatchableRotRutPayoutRequest,
|
||||
} from '@/lib/invoices/rot-rut-payout-matching'
|
||||
import { loadOpenRotRutPayoutRequests } from '@/lib/invoices/rot-rut-payout-candidates'
|
||||
import { matchTransactionsToRotRutPayoutSets } from '@/lib/invoices/rot-rut-payout-set-matching'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
groupExpenseClaimsByPerson,
|
||||
@@ -510,9 +517,101 @@ export async function listSuggestedMatches(
|
||||
for (const m of expenseMatches) {
|
||||
if (!seen.has(m.transaction_id)) matches.push(m)
|
||||
}
|
||||
// Skatteverket's bundled ROT/RUT payout (several begäran in one transfer):
|
||||
// no hint column either, recomputed from the open begäran. Confirm endpoint
|
||||
// is the same match-rot-rut-payout route, with request_ids.
|
||||
const setMatches = await listRotRutPayoutSetSuggestions(supabase, companyId, limit)
|
||||
for (const m of setMatches) {
|
||||
if (!seen.has(m.transaction_id)) {
|
||||
matches.push(m)
|
||||
seen.add(m.transaction_id)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
/** Newest unbooked income rows scanned for a bundled ROT/RUT payout. */
|
||||
const ROT_RUT_SET_SCAN_LIMIT = 200
|
||||
|
||||
/**
|
||||
* Unbooked SEK income rows that equal the SUM of several open begäran:
|
||||
* Skatteverket bundles the beslut it pays that day into one transfer
|
||||
* (#2239). Read-time pairing over the open pool, like the expense payouts:
|
||||
* no hint column, and nothing at all for a company without two open
|
||||
* begäran. The rule lives in lib/invoices/rot-rut-payout-set-matching.ts;
|
||||
* a single-begäran hit is the persisted 1:1 hint's job and is not repeated
|
||||
* here.
|
||||
*/
|
||||
export async function listRotRutPayoutSetSuggestions(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
limit = 20,
|
||||
): Promise<SuggestedMatch[]> {
|
||||
const pool = (await loadOpenRotRutPayoutRequests(supabase, companyId)).filter((request) =>
|
||||
isMatchableRotRutPayoutRequest(request),
|
||||
)
|
||||
if (pool.length < 2) return []
|
||||
const expected = pool.map((request) => expectedRotRutPayoutAmount(request))
|
||||
const minExpected = Math.min(...expected)
|
||||
const maxTotal = roundOre(expected.reduce((sum, amount) => sum + amount, 0))
|
||||
|
||||
// An invoice hint wins over a begäran (same precedence as the hint path),
|
||||
// so rows carrying one are not candidates. Rows with a 1:1 begäran hint
|
||||
// ARE fetched: the matcher skips them but takes their begäran out of the
|
||||
// pool, so a set never offers a request another row is about to settle.
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.select(
|
||||
'id, date, description, merchant_name, amount, currency, is_business, journal_entry_id, potential_rot_rut_payout_request_id',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.is('is_business', null)
|
||||
.eq('is_ignored', false)
|
||||
.is('journal_entry_id', null)
|
||||
.is('potential_invoice_id', null)
|
||||
.gte('amount', minExpected)
|
||||
.lte('amount', maxTotal)
|
||||
.order('date', { ascending: false })
|
||||
.limit(ROT_RUT_SET_SCAN_LIMIT)
|
||||
if (error) {
|
||||
log.error('worklist listRotRutPayoutSetSuggestions failed', { companyId, reason: error.message })
|
||||
return []
|
||||
}
|
||||
type TxRow = {
|
||||
id: string
|
||||
date: string
|
||||
description: string | null
|
||||
merchant_name: string | null
|
||||
amount: number
|
||||
currency: string | null
|
||||
is_business: boolean | null
|
||||
journal_entry_id: string | null
|
||||
potential_rot_rut_payout_request_id: string | null
|
||||
}
|
||||
const txs = (data ?? []) as TxRow[]
|
||||
const paired = matchTransactionsToRotRutPayoutSets(txs, pool)
|
||||
const out: SuggestedMatch[] = []
|
||||
for (const tx of txs) {
|
||||
const m = paired.get(tx.id)
|
||||
if (!m || m.requests.length < 2) continue
|
||||
out.push({
|
||||
transaction_id: tx.id,
|
||||
transaction_date: tx.date,
|
||||
transaction_description: tx.description ?? '',
|
||||
transaction_amount: tx.amount,
|
||||
transaction_currency: tx.currency ?? 'SEK',
|
||||
kind: 'rot_rut_payout',
|
||||
candidate_id: m.requests[0].id,
|
||||
candidate_number: m.requests.map((request) => request.name).join(' + '),
|
||||
counterparty_name: 'Skatteverket',
|
||||
candidate_total: m.total,
|
||||
request_ids: m.requests.map((request) => request.id),
|
||||
})
|
||||
if (out.length >= limit) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts not signed off through the end of the previous month. Cheap by
|
||||
* construction (three small reads, no bridge computation) and zero for
|
||||
|
||||
@@ -168,6 +168,13 @@ export interface SuggestedMatch {
|
||||
candidate_number: string | null
|
||||
counterparty_name: string | null
|
||||
candidate_total: number | null
|
||||
/**
|
||||
* rot_rut_payout only: every begäran the transfer settles. Absent for a
|
||||
* persisted 1:1 hint (candidate_id is the request); several when
|
||||
* Skatteverket paid a bundle of beslut in one transfer (#2239), in which
|
||||
* case candidate_number joins their names.
|
||||
*/
|
||||
request_ids?: string[]
|
||||
/** expense_payout only: the registered claims this transfer pays. */
|
||||
claim_ids?: string[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user