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:
Jakob Wennberg
2026-09-06 21:13:53 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 4bc007cdeb
commit 0069a3f59a
22 changed files with 1679 additions and 284 deletions
@@ -28,8 +28,10 @@ vi.mock('@/lib/init', () => ({
}))
const mockSettle = vi.fn()
const mockSettleSet = vi.fn()
vi.mock('@/lib/invoices/rot-rut-settle', () => ({
settleRotRutPayoutRequest: (...args: unknown[]) => mockSettle(...args),
settleRotRutPayoutRequestSet: (...args: unknown[]) => mockSettleSet(...args),
}))
const mockResolveSettlementAccount = vi.fn()
@@ -218,4 +220,80 @@ describe('POST /api/transactions/[id]/match-rot-rut-payout', () => {
const conflict = await POST(makeReq(), routeParams)
expect(conflict.status).toBe(409)
})
// Several begäran paid in ONE transfer (#2239): request_ids.
const REQUEST_ID_2 = '33333333-3333-4333-8333-333333333333'
it('returns 400 when neither or both of request_id and request_ids are given', async () => {
let response = await POST(makeReq({}), routeParams)
expect(response.status).toBe(400)
response = await POST(
makeReq({ request_id: REQUEST_ID, request_ids: [REQUEST_ID, REQUEST_ID_2] }),
routeParams,
)
expect(response.status).toBe(400)
expect(mockSettle).not.toHaveBeenCalled()
expect(mockSettleSet).not.toHaveBeenCalled()
})
it('settles a bundle through the set writer with the row amount, date and cash account', async () => {
enqueue({ data: makeTxRow({ amount: 5250 }) })
mockSettleSet.mockResolvedValue({
ok: true,
journalEntryId: 'je-set',
amount: 5250,
requests: [
{ id: REQUEST_ID, name: 'ROT 2026-07', status: 'paid' },
{ id: REQUEST_ID_2, name: 'RUT 2026-07', status: 'paid' },
],
})
const response = await POST(makeReq({ request_ids: [REQUEST_ID, REQUEST_ID_2] }), routeParams)
const { status, body } = await parseJsonResponse<{
success: boolean
journal_entry_id: string
requests: Array<{ id: string }>
category: string
}>(response)
expect(status).toBe(200)
expect(body).toMatchObject({ success: true, journal_entry_id: 'je-set', category: 'income_other' })
expect(body.requests.map((r) => r.id)).toEqual([REQUEST_ID, REQUEST_ID_2])
expect(mockSettleSet).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', {
requestIds: [REQUEST_ID, REQUEST_ID_2],
paymentDate: '2026-07-10',
amount: 5250,
bankAccount: '1930',
transactionId: TX_ID,
previousJournalEntryId: null,
})
expect(mockSettle).not.toHaveBeenCalled()
})
it('routes a one-element (or duplicated) request_ids through the single writer', async () => {
enqueue({ data: makeTxRow() })
const response = await POST(makeReq({ request_ids: [REQUEST_ID, REQUEST_ID] }), routeParams)
expect(response.status).toBe(200)
expect(mockSettle).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'company-1',
expect.objectContaining({ requestId: REQUEST_ID }),
)
expect(mockSettleSet).not.toHaveBeenCalled()
})
it('maps the set writer\x27s amount refusal onto the canonical envelope', async () => {
enqueue({ data: makeTxRow({ amount: 5000 }) })
mockSettleSet.mockResolvedValue({
ok: false,
kind: 'code',
code: 'ROT_RUT_SETTLE_SET_AMOUNT',
details: { amount: 5000, expected_total: 5250, request_ids: [REQUEST_ID, REQUEST_ID_2] },
})
const response = await POST(makeReq({ request_ids: [REQUEST_ID, REQUEST_ID_2] }), routeParams)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ROT_RUT_SETTLE_SET_AMOUNT')
})
})
@@ -4,7 +4,10 @@ import { validateBody } from '@/lib/api/validate'
import { MatchRotRutPayoutSchema } from '@/lib/api/schemas'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { settleRotRutPayoutRequest } from '@/lib/invoices/rot-rut-settle'
import {
settleRotRutPayoutRequest,
settleRotRutPayoutRequestSet,
} from '@/lib/invoices/rot-rut-settle'
import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry'
import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked'
import { ensureInitialized } from '@/lib/init'
@@ -23,6 +26,11 @@ ensureInitialized()
* date and bank account come from the bank row and the row is linked to the
* voucher in the same call, so the payout can never be booked twice (once by
* settle, once by categorising the bank row).
*
* Skatteverket bundles the beslut it pays that day into one transfer, so the
* body may name several begäran (`request_ids`, #2239): then ONE voucher
* carries one 1513 credit per begäran and the row is linked to it, provided
* the expected payouts sum to the row exactly.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'transaction.match_rot_rut_payout',
@@ -35,9 +43,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
operation: 'transaction.match_rot_rut_payout',
})
if (!validation.success) return validation.response
const { request_id: payoutRequestId } = validation.data
const payoutRequestIds = [
...new Set(validation.data.request_ids ?? [validation.data.request_id!]),
]
const txLog = log.child({ transactionId, payoutRequestId })
const txLog = log.child({ transactionId, payoutRequestIds })
// transaction_voucher_links rides along: a row bulk-booked into a
// samlingsverifikat carries journal_entry_id = NULL and must still refuse.
@@ -99,16 +109,27 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
txLog,
)
const outcome = await settleRotRutPayoutRequest(supabase, user.id, companyId!, {
requestId: payoutRequestId,
// Shared by both shapes: amount, date and account come from the bank row;
// the link CAS locks on the pointer read above (null for a free row, or
// the stale pointer of a reversed entry the guard let through).
const settleParams = {
paymentDate: transaction.date,
amount: transaction.amount,
bankAccount,
transactionId,
// Null for a free row, or the stale pointer of a reversed entry the
// guard above let through: the link CAS locks on exactly this value.
previousJournalEntryId: transaction.journal_entry_id,
})
}
const outcome =
payoutRequestIds.length === 1
? await settleRotRutPayoutRequest(supabase, user.id, companyId!, {
requestId: payoutRequestIds[0],
...settleParams,
})
: await settleRotRutPayoutRequestSet(supabase, user.id, companyId!, {
requestIds: payoutRequestIds,
...settleParams,
})
if (!outcome.ok) {
if (outcome.kind === 'code') {
@@ -124,13 +145,13 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
userId: user.id,
journalEntryId: outcome.journalEntryId,
amount: outcome.amount,
fullyPaid: outcome.fullyPaid,
fullyPaid: 'fullyPaid' in outcome ? outcome.fullyPaid : true,
})
return NextResponse.json({
success: true,
journal_entry_id: outcome.journalEntryId,
request: outcome.request,
...('request' in outcome ? { request: outcome.request } : { requests: outcome.requests }),
category: 'income_other',
})
},