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:
@@ -48,8 +48,10 @@ import type {
|
||||
CategorizeHandler,
|
||||
PotentialVoucher,
|
||||
PotentialRotRutPayout,
|
||||
PotentialRotRutPayoutRequest,
|
||||
} from '@/components/transactions/transaction-types'
|
||||
import { OPEN_ROT_RUT_PAYOUT_STATUSES } from '@/lib/invoices/rot-rut-payout-matching'
|
||||
import { matchTransactionsToRotRutPayoutSets } from '@/lib/invoices/rot-rut-payout-set-matching'
|
||||
import {
|
||||
groupExpenseClaimsByPerson,
|
||||
matchTransactionsToExpensePayouts,
|
||||
@@ -241,7 +243,15 @@ async function fetchExpensePayoutMatches(
|
||||
// prod schema cache (see DECISIONS.md 2026-07-06).
|
||||
async function fetchPotentialMatches(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string | null,
|
||||
rows: {
|
||||
id: string
|
||||
amount: number
|
||||
currency: string | null
|
||||
description?: string | null
|
||||
merchant_name?: string | null
|
||||
is_business: boolean | null
|
||||
journal_entry_id: string | null
|
||||
potential_invoice_id: string | null
|
||||
potential_supplier_invoice_id: string | null
|
||||
potential_rot_rut_payout_request_id?: string | null
|
||||
@@ -251,13 +261,6 @@ async function fetchPotentialMatches(
|
||||
const potentialInvoiceIds = Array.from(
|
||||
new Set(rows.flatMap((t) => (t.potential_invoice_id ? [t.potential_invoice_id] : []))),
|
||||
)
|
||||
const potentialRotRutRequestIds = Array.from(
|
||||
new Set(
|
||||
rows.flatMap((t) =>
|
||||
t.potential_rot_rut_payout_request_id ? [t.potential_rot_rut_payout_request_id] : [],
|
||||
),
|
||||
),
|
||||
)
|
||||
const potentialSupplierInvoiceIds = Array.from(
|
||||
new Set(rows.flatMap((t) => (t.potential_supplier_invoice_id ? [t.potential_supplier_invoice_id] : []))),
|
||||
)
|
||||
@@ -280,7 +283,7 @@ async function fetchPotentialMatches(
|
||||
// an unmatchable candidate must not reach the row or the match dialog, which
|
||||
// would otherwise compare the transaction against a 0 kr remaining balance
|
||||
// and call it a partial payment.
|
||||
const [invoiceResults, supplierInvoiceResults, voucherResults, rotRutResults] = await Promise.all([
|
||||
const [invoiceResults, supplierInvoiceResults, voucherResults, rotRutResult] = await Promise.all([
|
||||
Promise.all(
|
||||
chunks(potentialInvoiceIds).map((ids) =>
|
||||
supabase
|
||||
@@ -314,21 +317,22 @@ async function fetchPotentialMatches(
|
||||
.eq('status', 'posted'),
|
||||
),
|
||||
),
|
||||
// Open ROT/RUT begäran (Skatteverkets utbetalning). Revalidated like the
|
||||
// invoice hints: a request settled by another row must not reach the
|
||||
// dialog. Items ride along so the dialog can list the covered invoices.
|
||||
Promise.all(
|
||||
chunks(potentialRotRutRequestIds).map((ids) =>
|
||||
supabase
|
||||
// Open ROT/RUT begäran (Skatteverkets utbetalning): the company's whole
|
||||
// open pool, not the hinted ids. It serves both the persisted 1:1 hint
|
||||
// (revalidated: a request settled by another row must not reach the
|
||||
// dialog) and the read-time covering set for a row Skatteverket paid
|
||||
// together with other beslut (#2239). Items ride along so the dialog can
|
||||
// list the covered invoices. A handful of rows per company.
|
||||
companyId
|
||||
? supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.select(
|
||||
'id, name, deduction_type, status, requested_total, decided_total, settlement_journal_entry_id, items:rot_rut_payout_request_items(requested_amount, invoice:invoices(invoice_number))',
|
||||
)
|
||||
.in('id', ids)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', [...OPEN_ROT_RUT_PAYOUT_STATUSES])
|
||||
.is('settlement_journal_entry_id', null),
|
||||
),
|
||||
),
|
||||
.is('settlement_journal_entry_id', null)
|
||||
: Promise.resolve({ data: null, error: null }),
|
||||
])
|
||||
|
||||
// Non-fatal: the transaction list still renders without match hints, but
|
||||
@@ -342,12 +346,11 @@ async function fetchPotentialMatches(
|
||||
for (const r of voucherResults) {
|
||||
if (r.error) console.error('[fetchPotentialMatches] journal_entries query failed', r.error)
|
||||
}
|
||||
for (const r of rotRutResults) {
|
||||
if (r.error) console.error('[fetchPotentialMatches] rot_rut_payout_requests query failed', r.error)
|
||||
if (rotRutResult.error) {
|
||||
console.error('[fetchPotentialMatches] rot_rut_payout_requests query failed', rotRutResult.error)
|
||||
}
|
||||
|
||||
const rotRutMap: Record<string, PotentialRotRutPayout> = {}
|
||||
for (const req of rotRutResults.flatMap((r) => (r.data ?? []) as Array<{
|
||||
const rotRutRequests: PotentialRotRutPayoutRequest[] = ((rotRutResult.data ?? []) as Array<{
|
||||
id: string
|
||||
name: string
|
||||
deduction_type: 'rot' | 'rut'
|
||||
@@ -359,20 +362,31 @@ async function fetchPotentialMatches(
|
||||
requested_amount: number | string
|
||||
invoice?: { invoice_number: string | null } | { invoice_number: string | null }[] | null
|
||||
}> | null
|
||||
}>)) {
|
||||
rotRutMap[req.id] = {
|
||||
id: req.id,
|
||||
name: req.name,
|
||||
deduction_type: req.deduction_type,
|
||||
status: req.status,
|
||||
requested_total: req.requested_total,
|
||||
decided_total: req.decided_total,
|
||||
settlement_journal_entry_id: req.settlement_journal_entry_id,
|
||||
invoices: (req.items ?? []).map((item) => {
|
||||
const inv = Array.isArray(item.invoice) ? item.invoice[0] : item.invoice
|
||||
return { invoice_number: inv?.invoice_number ?? null, requested_amount: item.requested_amount }
|
||||
}),
|
||||
}
|
||||
}>).map((req) => ({
|
||||
id: req.id,
|
||||
name: req.name,
|
||||
deduction_type: req.deduction_type,
|
||||
status: req.status,
|
||||
requested_total: req.requested_total,
|
||||
decided_total: req.decided_total,
|
||||
settlement_journal_entry_id: req.settlement_journal_entry_id,
|
||||
invoices: (req.items ?? []).map((item) => {
|
||||
const inv = Array.isArray(item.invoice) ? item.invoice[0] : item.invoice
|
||||
return { invoice_number: inv?.invoice_number ?? null, requested_amount: item.requested_amount }
|
||||
}),
|
||||
}))
|
||||
const rotRutById = new Map(rotRutRequests.map((req) => [req.id, req] as const))
|
||||
// Per row: the persisted 1:1 hint when its begäran is still open, else the
|
||||
// exact covering set over the open pool (several begäran in one transfer).
|
||||
const rotRutByTransaction = new Map<string, PotentialRotRutPayout>()
|
||||
for (const row of rows) {
|
||||
const hinted = row.potential_rot_rut_payout_request_id
|
||||
? rotRutById.get(row.potential_rot_rut_payout_request_id)
|
||||
: undefined
|
||||
if (hinted) rotRutByTransaction.set(row.id, { requests: [hinted] })
|
||||
}
|
||||
for (const [txId, match] of matchTransactionsToRotRutPayoutSets(rows, rotRutRequests)) {
|
||||
rotRutByTransaction.set(txId, { requests: match.requests })
|
||||
}
|
||||
|
||||
const voucherMap: Record<string, PotentialVoucher> = {}
|
||||
@@ -396,7 +410,7 @@ async function fetchPotentialMatches(
|
||||
invoiceMap: buildInvoiceMap(invoiceResults.flatMap((r) => r.data ?? [])),
|
||||
supplierInvoiceMap: buildSupplierInvoiceMap(supplierInvoiceResults.flatMap((r) => r.data ?? [])),
|
||||
voucherMap,
|
||||
rotRutMap,
|
||||
rotRutByTransaction,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1171,8 +1185,8 @@ export default function TransactionsPage() {
|
||||
const windowIds = new Set(rows.map((r) => r.id))
|
||||
const olderPending = (pendingRows ?? []).filter((r) => !windowIds.has(r.id))
|
||||
const allRows = [...rows, ...olderPending].sort((a, b) => b.date.localeCompare(a.date))
|
||||
const [{ invoiceMap, supplierInvoiceMap, voucherMap, rotRutMap }, expensePayouts] = await Promise.all([
|
||||
fetchPotentialMatches(supabase, allRows),
|
||||
const [{ invoiceMap, supplierInvoiceMap, voucherMap, rotRutByTransaction }, expensePayouts] = await Promise.all([
|
||||
fetchPotentialMatches(supabase, companyId, allRows),
|
||||
fetchExpensePayoutMatches(supabase, companyId, allRows),
|
||||
])
|
||||
const expensePayoutMap = expensePayouts.byTransaction
|
||||
@@ -1189,9 +1203,7 @@ export default function TransactionsPage() {
|
||||
potential_supplier_invoice: t.potential_supplier_invoice_id
|
||||
? supplierInvoiceMap[t.potential_supplier_invoice_id]
|
||||
: undefined,
|
||||
potential_rot_rut_payout: t.potential_rot_rut_payout_request_id
|
||||
? rotRutMap[t.potential_rot_rut_payout_request_id]
|
||||
: undefined,
|
||||
potential_rot_rut_payout: rotRutByTransaction.get(t.id),
|
||||
potential_voucher: t.potential_journal_entry_id
|
||||
? voucherMap[t.potential_journal_entry_id]
|
||||
: undefined,
|
||||
@@ -1265,8 +1277,8 @@ export default function TransactionsPage() {
|
||||
setPagedThroughDate(txData.length >= PAGE_SIZE ? txData[txData.length - 1].date : null)
|
||||
setHasMore(txData.length >= PAGE_SIZE)
|
||||
|
||||
const [{ invoiceMap, supplierInvoiceMap, voucherMap, rotRutMap }, expensePayouts] = await Promise.all([
|
||||
fetchPotentialMatches(supabase, txData),
|
||||
const [{ invoiceMap, supplierInvoiceMap, voucherMap, rotRutByTransaction }, expensePayouts] = await Promise.all([
|
||||
fetchPotentialMatches(supabase, companyId, txData),
|
||||
fetchExpensePayoutMatches(supabase, companyId, txData),
|
||||
])
|
||||
const expensePayoutMap = expensePayouts.byTransaction
|
||||
@@ -1285,9 +1297,7 @@ export default function TransactionsPage() {
|
||||
potential_supplier_invoice: t.potential_supplier_invoice_id
|
||||
? supplierInvoiceMap[t.potential_supplier_invoice_id]
|
||||
: undefined,
|
||||
potential_rot_rut_payout: t.potential_rot_rut_payout_request_id
|
||||
? rotRutMap[t.potential_rot_rut_payout_request_id]
|
||||
: undefined,
|
||||
potential_rot_rut_payout: rotRutByTransaction.get(t.id),
|
||||
potential_voucher: t.potential_journal_entry_id
|
||||
? voucherMap[t.potential_journal_entry_id]
|
||||
: undefined,
|
||||
@@ -2393,15 +2403,18 @@ export default function TransactionsPage() {
|
||||
|
||||
async function handleConfirmRotRutPayoutMatch() {
|
||||
if (!selectedTransaction?.potential_rot_rut_payout) return
|
||||
const request = selectedTransaction.potential_rot_rut_payout
|
||||
const { requests } = selectedTransaction.potential_rot_rut_payout
|
||||
if (requests.length === 0) return
|
||||
setIsConfirmingMatch(true)
|
||||
try {
|
||||
// One begäran or a bundle Skatteverket paid together: the route books
|
||||
// one voucher either way and takes the ids as request_ids.
|
||||
const response = await fetch(
|
||||
`/api/transactions/${selectedTransaction.id}/match-rot-rut-payout`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ request_id: request.id }),
|
||||
body: JSON.stringify({ request_ids: requests.map((request) => request.id) }),
|
||||
},
|
||||
)
|
||||
const result = await response.json()
|
||||
@@ -2417,7 +2430,13 @@ export default function TransactionsPage() {
|
||||
|
||||
toast({
|
||||
title: t('rot_rut_payout_matched_title'),
|
||||
description: t('rot_rut_payout_matched_description', { name: request.name }),
|
||||
description:
|
||||
requests.length === 1
|
||||
? t('rot_rut_payout_matched_description', { name: requests[0].name })
|
||||
: t('rot_rut_payout_matched_set_description', {
|
||||
count: requests.length,
|
||||
names: requests.map((request) => request.name).join(', '),
|
||||
}),
|
||||
})
|
||||
setRotRutMatchDialogOpen(false)
|
||||
|
||||
@@ -2694,7 +2713,7 @@ export default function TransactionsPage() {
|
||||
setMatchDialogOpen(true)
|
||||
}
|
||||
|
||||
function handleSelectRotRutPayoutFromPicker(request: PotentialRotRutPayout) {
|
||||
function handleSelectRotRutPayoutFromPicker(request: PotentialRotRutPayoutRequest) {
|
||||
if (!invoicePickerTransaction) return
|
||||
// Same handoff as the invoice pick: close the picker, hang the request on
|
||||
// the row and open the ROT/RUT confirm dialog so the user sees the
|
||||
@@ -2702,7 +2721,7 @@ export default function TransactionsPage() {
|
||||
const tx = invoicePickerTransaction
|
||||
setInvoicePickerOpen(false)
|
||||
setInvoicePickerTransaction(null)
|
||||
setSelectedTransaction({ ...tx, potential_rot_rut_payout: request })
|
||||
setSelectedTransaction({ ...tx, potential_rot_rut_payout: { requests: [request] } })
|
||||
setRotRutMatchDialogOpen(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
},
|
||||
|
||||
@@ -173,7 +173,7 @@ export default function AttGoraSection({
|
||||
match.kind === 'invoice'
|
||||
? { invoice_id: match.candidate_id }
|
||||
: match.kind === 'rot_rut_payout'
|
||||
? { request_id: match.candidate_id }
|
||||
? { request_ids: match.request_ids ?? [match.candidate_id] }
|
||||
: match.kind === 'expense_payout'
|
||||
? { claim_ids: match.claim_ids ?? [] }
|
||||
: { supplier_invoice_id: match.candidate_id }
|
||||
|
||||
@@ -9,7 +9,7 @@ import { roundOre } from '@/lib/money'
|
||||
import { Search, FileText, Loader2, Landmark } from 'lucide-react'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { Invoice, Customer } from '@/types'
|
||||
import type { PotentialRotRutPayout, TransactionWithInvoice } from './transaction-types'
|
||||
import type { PotentialRotRutPayoutRequest, TransactionWithInvoice } from './transaction-types'
|
||||
import {
|
||||
DOMESTIC_CURRENCY,
|
||||
normalizeCurrency,
|
||||
@@ -27,7 +27,7 @@ interface InvoicePickerProps {
|
||||
onSelect: (invoice: OpenInvoice) => void
|
||||
/** Pick an open ROT/RUT begäran instead of an invoice (Skatteverkets
|
||||
* utbetalning). The section only renders when the company has one. */
|
||||
onSelectRotRutPayout?: (request: PotentialRotRutPayout) => void
|
||||
onSelectRotRutPayout?: (request: PotentialRotRutPayoutRequest) => void
|
||||
}
|
||||
|
||||
type RotRutRequestRow = {
|
||||
@@ -49,7 +49,7 @@ export default function InvoicePicker({ transaction, onSelect, onSelectRotRutPay
|
||||
const { company } = useCompany()
|
||||
const supabase = useMemo(() => createClient(), [])
|
||||
const [invoices, setInvoices] = useState<OpenInvoice[]>([])
|
||||
const [rotRutRequests, setRotRutRequests] = useState<PotentialRotRutPayout[]>([])
|
||||
const [rotRutRequests, setRotRutRequests] = useState<PotentialRotRutPayoutRequest[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
// Boolean, not the callback: a fresh function identity per parent render
|
||||
|
||||
@@ -29,13 +29,16 @@ interface RotRutPayoutMatchDialogProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm dialog for matching an income bank row to an open ROT/RUT begäran:
|
||||
* Skatteverkets utbetalning clears the 1513 receivable (debit the row's cash
|
||||
* account, credit 1513) and the row is linked to that voucher.
|
||||
* Confirm dialog for matching an income bank row to one or several open
|
||||
* ROT/RUT begäran: Skatteverkets utbetalning clears the 1513 receivable
|
||||
* (debit the row's cash account, one 1513 credit per begäran) and the row is
|
||||
* linked to that voucher.
|
||||
*
|
||||
* Kept separate from InvoiceMatchDialog on purpose: no FX, no preview fetch,
|
||||
* no editable lines. The entry has exactly two legs and the amount is the
|
||||
* bank row's, so everything the user needs to approve is known up front.
|
||||
* no editable lines. The legs are known up front, so everything the user
|
||||
* needs to approve is on screen. A bundle (several begäran paid in one
|
||||
* transfer) is booked at exactly the decided sums: partial and over
|
||||
* variants exist only for a single begäran.
|
||||
*/
|
||||
export default function RotRutPayoutMatchDialog({
|
||||
open,
|
||||
@@ -45,29 +48,41 @@ export default function RotRutPayoutMatchDialog({
|
||||
onConfirm,
|
||||
}: RotRutPayoutMatchDialogProps) {
|
||||
const t = useTranslations('tx_rot_rut_match')
|
||||
const request = transaction?.potential_rot_rut_payout ?? null
|
||||
const requests = transaction?.potential_rot_rut_payout?.requests ?? []
|
||||
const isSet = requests.length > 1
|
||||
const single = requests.length === 1 ? requests[0] : null
|
||||
|
||||
const targetState = getRotRutPayoutMatchTargetState(request)
|
||||
const targetBlocked = targetState !== 'matchable'
|
||||
// Every begäran must still be open and unsettled; the first blocked one
|
||||
// explains the refusal.
|
||||
const blockedState =
|
||||
requests.map((request) => getRotRutPayoutMatchTargetState(request)).find((s) => s !== 'matchable') ??
|
||||
null
|
||||
const targetBlocked = requests.length === 0 || blockedState !== null
|
||||
|
||||
const txAmount = transaction ? roundOre(transaction.amount) : 0
|
||||
const expected = request ? expectedRotRutPayoutAmount(request) : 0
|
||||
const requestedTotal = request ? roundOre(Number(request.requested_total)) : 0
|
||||
const expected = roundOre(
|
||||
requests.reduce((sum, request) => sum + expectedRotRutPayoutAmount(request), 0),
|
||||
)
|
||||
const requestedTotal = roundOre(
|
||||
requests.reduce((sum, request) => sum + roundOre(Number(request.requested_total)), 0),
|
||||
)
|
||||
const diff = roundOre(Math.abs(txAmount - expected))
|
||||
const amountsMatch = diff < 0.01
|
||||
// The settle service refuses a payout below requested_total unless the
|
||||
// beslut (decided_total) is recorded: say so here instead of letting the
|
||||
// button fail.
|
||||
const isPartial = request ? txAmount < requestedTotal - 0.005 : false
|
||||
const partialBlocked = isPartial && request?.decided_total == null
|
||||
const isPartial = single ? txAmount < requestedTotal - 0.005 : false
|
||||
const partialBlocked = isPartial && single?.decided_total == null
|
||||
// The service refuses more than Skatteverket can owe on this begäran: a
|
||||
// larger row would drive 1513 negative. Block here too, with the reason.
|
||||
const overBlocked = request ? txAmount > expected + 0.005 : false
|
||||
const overBlocked = single ? txAmount > expected + 0.005 : false
|
||||
// A bundle only ever books the exact sum of its begäran.
|
||||
const setBlocked = isSet && !amountsMatch
|
||||
// Skatteverket pays out in SEK only; the route refuses anything else.
|
||||
const currencyBlocked = (transaction?.currency || 'SEK').toUpperCase() !== 'SEK'
|
||||
const currency = transaction?.currency || 'SEK'
|
||||
|
||||
const typeLabel = request?.deduction_type === 'rut' ? 'RUT' : 'ROT'
|
||||
const typeLabel = (deductionType: 'rot' | 'rut') => (deductionType === 'rut' ? 'RUT' : 'ROT')
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -79,7 +94,7 @@ export default function RotRutPayoutMatchDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{transaction && request && (
|
||||
{transaction && requests.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">{t('transaction_label')}</p>
|
||||
@@ -92,32 +107,50 @@ export default function RotRutPayoutMatchDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">{t('request_label')}</p>
|
||||
<p className="font-medium">{t('request_name', { type: typeLabel, name: request.name })}</p>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t('requested_total', { amount: formatCurrency(requestedTotal, 'SEK') })}
|
||||
</span>
|
||||
{request.decided_total != null && (
|
||||
<span className="text-muted-foreground">
|
||||
{t('decided_total', { amount: formatCurrency(Number(request.decided_total), 'SEK') })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{request.invoices.length > 0 && (
|
||||
<div className="pt-2 border-t space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t('invoices_title')}</p>
|
||||
<ul className="text-sm space-y-0.5">
|
||||
{request.invoices.map((inv, i) => (
|
||||
<li key={`${inv.invoice_number ?? 'x'}-${i}`} className="flex justify-between">
|
||||
<span>{t('invoice_row', { number: inv.invoice_number ?? '' })}</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{formatCurrency(Number(inv.requested_amount), 'SEK')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{isSet ? t('requests_label', { count: requests.length }) : t('request_label')}
|
||||
</p>
|
||||
{requests.map((request) => (
|
||||
<div key={request.id} className="space-y-2">
|
||||
<p className="font-medium">
|
||||
{t('request_name', { type: typeLabel(request.deduction_type), name: request.name })}
|
||||
</p>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t('requested_total', {
|
||||
amount: formatCurrency(roundOre(Number(request.requested_total)), 'SEK'),
|
||||
})}
|
||||
</span>
|
||||
{request.decided_total != null && (
|
||||
<span className="text-muted-foreground">
|
||||
{t('decided_total', {
|
||||
amount: formatCurrency(Number(request.decided_total), 'SEK'),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{request.invoices.length > 0 && (
|
||||
<div className="pt-2 border-t space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t('invoices_title')}</p>
|
||||
<ul className="text-sm space-y-0.5">
|
||||
{request.invoices.map((inv, i) => (
|
||||
<li key={`${inv.invoice_number ?? 'x'}-${i}`} className="flex justify-between">
|
||||
<span>{t('invoice_row', { number: inv.invoice_number ?? '' })}</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{formatCurrency(Number(inv.requested_amount), 'SEK')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isSet && (
|
||||
<div className="flex justify-between border-t pt-2 text-sm font-medium">
|
||||
<span>{t('requests_total_label')}</span>
|
||||
<span className="tabular-nums">{formatCurrency(expected, 'SEK')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -127,11 +160,11 @@ export default function RotRutPayoutMatchDialog({
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">
|
||||
{t(targetState === 'settled' ? 'target_settled_title' : 'target_not_open_title')}
|
||||
{t(blockedState === 'settled' ? 'target_settled_title' : 'target_not_open_title')}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
targetState === 'settled'
|
||||
blockedState === 'settled'
|
||||
? 'target_settled_description'
|
||||
: 'target_not_open_description',
|
||||
)}
|
||||
@@ -141,14 +174,17 @@ export default function RotRutPayoutMatchDialog({
|
||||
) : amountsMatch ? (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-success/10 text-success">
|
||||
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
|
||||
<p className="text-sm font-medium">{t('amounts_match')}</p>
|
||||
<p className="text-sm font-medium">
|
||||
{isSet ? t('amounts_match_set') : t('amounts_match')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/30 text-attn">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">{t('amounts_differ')}</p>
|
||||
<p className="font-medium">{isSet ? t('amounts_differ_set') : t('amounts_differ')}</p>
|
||||
<p>{t('amount_diff', { amount: formatCurrency(diff, currency) })}</p>
|
||||
{setBlocked && <p className="mt-1">{t('set_exact_required')}</p>}
|
||||
{overBlocked && <p className="mt-1">{t('over_payout_blocked')}</p>}
|
||||
{partialBlocked && <p className="mt-1">{t('partial_requires_beslut')}</p>}
|
||||
{isPartial && !partialBlocked && (
|
||||
@@ -169,13 +205,27 @@ export default function RotRutPayoutMatchDialog({
|
||||
</span>
|
||||
<span className="tabular-nums">{formatCurrency(txAmount, currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>
|
||||
<span className="text-muted-foreground">{t('booking_credit')}</span>{' '}
|
||||
{t('booking_receivable_line')}
|
||||
</span>
|
||||
<span className="tabular-nums">{formatCurrency(txAmount, currency)}</span>
|
||||
</div>
|
||||
{isSet ? (
|
||||
requests.map((request) => (
|
||||
<div key={request.id} className="flex justify-between">
|
||||
<span>
|
||||
<span className="text-muted-foreground">{t('booking_credit')}</span>{' '}
|
||||
{t('booking_receivable_line_named', { name: request.name })}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{formatCurrency(expectedRotRutPayoutAmount(request), currency)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex justify-between">
|
||||
<span>
|
||||
<span className="text-muted-foreground">{t('booking_credit')}</span>{' '}
|
||||
{t('booking_receivable_line')}
|
||||
</span>
|
||||
<span className="tabular-nums">{formatCurrency(txAmount, currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -185,7 +235,9 @@ export default function RotRutPayoutMatchDialog({
|
||||
<p className="text-sm font-medium">{t('on_confirm_title')}</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• {t('on_confirm_link')}</li>
|
||||
<li>• {t('on_confirm_request')}</li>
|
||||
<li>
|
||||
• {isSet ? t('on_confirm_requests', { count: requests.length }) : t('on_confirm_request')}
|
||||
</li>
|
||||
<li>• {t('on_confirm_voucher')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -200,7 +252,13 @@ export default function RotRutPayoutMatchDialog({
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={
|
||||
isConfirming || !request || targetBlocked || partialBlocked || overBlocked || currencyBlocked
|
||||
isConfirming ||
|
||||
requests.length === 0 ||
|
||||
targetBlocked ||
|
||||
partialBlocked ||
|
||||
overBlocked ||
|
||||
setBlocked ||
|
||||
currencyBlocked
|
||||
}
|
||||
>
|
||||
{isConfirming ? t('confirming') : t('confirm')}
|
||||
|
||||
@@ -186,9 +186,10 @@ 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
|
||||
// Skatteverkets ROT/RUT-utbetalning for one or several open begäran: same
|
||||
// 1-click shortcut as an invoice match, confirmed in its own dialog.
|
||||
const rotRutRequests = transaction.potential_rot_rut_payout?.requests ?? []
|
||||
const hasRotRutPayoutMatch = rotRutRequests.length > 0 && !transaction.journal_entry_id
|
||||
// A transfer that repays one person's registered utlägg to the öre: same
|
||||
// 1-click shortcut, confirmed in its own dialog.
|
||||
const hasExpensePayoutMatch = !!transaction.potential_expense_payout && !transaction.journal_entry_id
|
||||
@@ -214,7 +215,9 @@ export default function TransactionInboxCard({
|
||||
number: transaction.potential_supplier_invoice!.supplier_invoice_number ?? '',
|
||||
})
|
||||
: hasRotRutPayoutMatch
|
||||
? t('match_rot_rut_payout_btn', { name: transaction.potential_rot_rut_payout!.name })
|
||||
? rotRutRequests.length === 1
|
||||
? t('match_rot_rut_payout_btn', { name: rotRutRequests[0].name })
|
||||
: t('match_rot_rut_payout_set_btn', { count: rotRutRequests.length })
|
||||
: hasExpensePayoutMatch
|
||||
? t('match_expense_payout_btn', { name: transaction.potential_expense_payout!.claimant_name })
|
||||
: null
|
||||
|
||||
@@ -2,12 +2,20 @@ import type { Transaction, TransactionCategory, Invoice, Customer, SupplierInvoi
|
||||
import type { RotRutPayoutRequestCandidate } from '@/lib/invoices/rot-rut-payout-matching'
|
||||
import type { ExpensePayoutDue } from '@/lib/worklist/types'
|
||||
|
||||
/** 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 {
|
||||
/** An open ROT/RUT begäran offered to an income row, with the invoices it
|
||||
* covers (so the user sees which fakturor the payout settles). */
|
||||
export interface PotentialRotRutPayoutRequest extends RotRutPayoutRequestCandidate {
|
||||
invoices: Array<{ invoice_number: string | null; requested_amount: number | string }>
|
||||
}
|
||||
|
||||
/** The ROT/RUT match suggestion hung onto an income row: the begäran
|
||||
* Skatteverkets utbetalning settles. One for a persisted 1:1 hint or a
|
||||
* manual pick; several when the transfer bundles the beslut paid that day
|
||||
* (computed at read time, lib/invoices/rot-rut-payout-set-matching.ts). */
|
||||
export interface PotentialRotRutPayout {
|
||||
requests: PotentialRotRutPayoutRequest[]
|
||||
}
|
||||
|
||||
/** Revalidated journal-entry match suggestion hung onto a row (mirrors
|
||||
* potential_invoice): present only when the suggested entry is still posted. */
|
||||
export interface PotentialVoucher {
|
||||
|
||||
+14
-6
@@ -2179,13 +2179,21 @@ export const CreateTransactionFromDocumentSchema = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* POST /api/transactions/[id]/match-rot-rut-payout: settle one or several
|
||||
* 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(s): `request_id` for one begäran, `request_ids` when
|
||||
* Skatteverket paid several beslut in one transfer (#2239). Exactly one of
|
||||
* the two.
|
||||
*/
|
||||
export const MatchRotRutPayoutSchema = z.object({
|
||||
request_id: uuid,
|
||||
})
|
||||
export const MatchRotRutPayoutSchema = z
|
||||
.object({
|
||||
request_id: uuid.optional(),
|
||||
request_ids: z.array(uuid).min(1).max(10).optional(),
|
||||
})
|
||||
.refine((body) => (body.request_id ? 1 : 0) + (body.request_ids ? 1 : 0) === 1, {
|
||||
message: 'Ange antingen request_id eller request_ids',
|
||||
})
|
||||
|
||||
/** Bank outflow → the registered utlägg it repays (one person). */
|
||||
export const MatchExpensePayoutSchema = z.object({
|
||||
|
||||
@@ -1,22 +1,110 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { CreateJournalEntryInput, JournalEntry } from '@/types'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { createJournalEntry, findFiscalPeriod } from './engine'
|
||||
|
||||
/**
|
||||
* Settlement voucher for a rot/rut payout from Skatteverket.
|
||||
*
|
||||
* When the agency pays out a begäran (one lump sum per request), the 1513
|
||||
* receivable created at invoicing (fakturamodellen) clears against the bank:
|
||||
* When the agency pays out a begäran, the 1513 receivable created at
|
||||
* invoicing (fakturamodellen) clears against the bank:
|
||||
*
|
||||
* Debit 19xx bank account (default 1930) [amount]
|
||||
* Credit 1513 Skattereduktion rot/rut [amount]
|
||||
*
|
||||
* One voucher per payout request: that mirrors the actual bank transaction.
|
||||
* One voucher per bank transfer: that mirrors the actual bank transaction.
|
||||
* Skatteverket decides per begäran but pays everything it decided that day
|
||||
* in ONE transfer, so a voucher may clear several begäran: one 1513 leg per
|
||||
* request (createRotRutPayoutSetEntry), the way match-batch books one bank
|
||||
* row against several customer invoices. The request side of the link is
|
||||
* rot_rut_payout_requests.settlement_journal_entry_id on every request the
|
||||
* voucher settles; source_id carries the first of them.
|
||||
*
|
||||
* At partial approval (delvis beviljad) the paid amount clears here and the
|
||||
* remainder stays on 1513 until the user corrects it (kundfordran/kundförlust
|
||||
* depending on the outcome with the buyer): deliberately manual, never
|
||||
* guessed.
|
||||
*/
|
||||
export interface RotRutPayoutLeg {
|
||||
requestId: string
|
||||
requestName: string
|
||||
deductionType: 'rot' | 'rut'
|
||||
/** What Skatteverket paid for this begäran (kr). */
|
||||
amount: number
|
||||
}
|
||||
|
||||
function deductionLabel(legs: Array<Pick<RotRutPayoutLeg, 'deductionType'>>): string {
|
||||
const types = new Set(legs.map((leg) => leg.deductionType))
|
||||
if (types.size === 1) return types.has('rut') ? 'RUT' : 'ROT'
|
||||
return 'ROT/RUT'
|
||||
}
|
||||
|
||||
function payoutDescription(label: string, names: string[]): string {
|
||||
return `Utbetalning ${label}-avdrag från Skatteverket (${names.join(', ')})`
|
||||
}
|
||||
|
||||
export async function createRotRutPayoutSetEntry(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
params: {
|
||||
paymentDate: string
|
||||
/** BAS 19xx account the payout landed on. Defaults to 1930. */
|
||||
bankAccount?: string
|
||||
/** One leg per begäran the transfer settles; at least one. */
|
||||
legs: RotRutPayoutLeg[]
|
||||
},
|
||||
): Promise<JournalEntry> {
|
||||
if (params.legs.length === 0) {
|
||||
throw new Error('A rot/rut payout voucher needs at least one begäran')
|
||||
}
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, params.paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
throw new Error(`No open fiscal period found for payment date ${params.paymentDate}`)
|
||||
}
|
||||
|
||||
const legs = params.legs.map((leg) => ({
|
||||
...leg,
|
||||
amount: roundOre(leg.amount),
|
||||
}))
|
||||
const total = roundOre(legs.reduce((sum, leg) => sum + leg.amount, 0))
|
||||
const bankAccount = params.bankAccount ?? '1930'
|
||||
const description = payoutDescription(
|
||||
deductionLabel(legs),
|
||||
legs.map((leg) => leg.requestName),
|
||||
)
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: params.paymentDate,
|
||||
description,
|
||||
source_type: 'rot_rut_payout',
|
||||
source_id: legs[0].requestId,
|
||||
lines: [
|
||||
{
|
||||
account_number: bankAccount,
|
||||
debit_amount: total,
|
||||
credit_amount: 0,
|
||||
line_description: description,
|
||||
},
|
||||
...legs.map((leg) => ({
|
||||
account_number: '1513',
|
||||
debit_amount: 0,
|
||||
credit_amount: leg.amount,
|
||||
// A single begäran keeps the voucher text on both legs (as before);
|
||||
// a bundle names its own begäran on each 1513 leg.
|
||||
line_description:
|
||||
legs.length === 1
|
||||
? description
|
||||
: payoutDescription(deductionLabel([leg]), [leg.requestName]),
|
||||
})),
|
||||
],
|
||||
}
|
||||
|
||||
return createJournalEntry(supabase, companyId, userId, input)
|
||||
}
|
||||
|
||||
/** One begäran: the bundle voucher with a single 1513 leg. */
|
||||
export async function createRotRutPayoutEntry(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
@@ -31,37 +119,16 @@ export async function createRotRutPayoutEntry(
|
||||
bankAccount?: string
|
||||
},
|
||||
): Promise<JournalEntry> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, params.paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
throw new Error(`No open fiscal period found for payment date ${params.paymentDate}`)
|
||||
}
|
||||
|
||||
const amount = Math.round(params.amount * 100) / 100
|
||||
const bankAccount = params.bankAccount ?? '1930'
|
||||
const label = params.deductionType === 'rot' ? 'ROT' : 'RUT'
|
||||
const description = `Utbetalning ${label}-avdrag från Skatteverket (${params.requestName})`
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: params.paymentDate,
|
||||
description,
|
||||
source_type: 'rot_rut_payout',
|
||||
source_id: params.requestId,
|
||||
lines: [
|
||||
return createRotRutPayoutSetEntry(supabase, companyId, userId, {
|
||||
paymentDate: params.paymentDate,
|
||||
bankAccount: params.bankAccount,
|
||||
legs: [
|
||||
{
|
||||
account_number: bankAccount,
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: description,
|
||||
},
|
||||
{
|
||||
account_number: '1513',
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: description,
|
||||
requestId: params.requestId,
|
||||
requestName: params.requestName,
|
||||
deductionType: params.deductionType,
|
||||
amount: params.amount,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return createJournalEntry(supabase, companyId, userId, input)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -977,6 +977,13 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
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_SET_AMOUNT: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Beloppet stämmer inte med summan av de valda begäran. Skatteverket betalar ut exakt beslutade belopp, så flera begäran kan bara bokföras tillsammans när transaktionen motsvarar summan till öret.',
|
||||
message_en:
|
||||
'The amount does not equal the sum of the selected requests. Skatteverket pays exactly the decided amounts, so several requests can only be booked together when the transaction equals their sum to the öre.',
|
||||
},
|
||||
ROT_RUT_SETTLE_RACE: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { makeTransaction } from '@/tests/helpers'
|
||||
import type { RotRutPayoutRequestCandidate } from '../rot-rut-payout-matching'
|
||||
import {
|
||||
findRotRutPayoutSetMatch,
|
||||
matchTransactionsToRotRutPayoutSets,
|
||||
} from '../rot-rut-payout-set-matching'
|
||||
|
||||
function makeRequest(
|
||||
id: string,
|
||||
requestedTotal: number,
|
||||
overrides: Partial<RotRutPayoutRequestCandidate> = {},
|
||||
): RotRutPayoutRequestCandidate {
|
||||
return {
|
||||
id,
|
||||
name: `ROT ${id}`,
|
||||
deduction_type: 'rot',
|
||||
status: 'submitted',
|
||||
requested_total: requestedTotal,
|
||||
decided_total: null,
|
||||
settlement_journal_entry_id: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const A = makeRequest('rr-a', 3000)
|
||||
const B = makeRequest('rr-b', 2250)
|
||||
const C = makeRequest('rr-c', 1200.5)
|
||||
|
||||
describe('findRotRutPayoutSetMatch', () => {
|
||||
it('returns null with no open requests or no closing set', () => {
|
||||
expect(findRotRutPayoutSetMatch(makeTransaction({ amount: 3000 }), [])).toBeNull()
|
||||
// 3000 + 2250 = 5250; 5000 closes nothing.
|
||||
expect(findRotRutPayoutSetMatch(makeTransaction({ amount: 5000 }), [A, B])).toBeNull()
|
||||
})
|
||||
|
||||
it('matches a single begäran exactly like the 1:1 matcher', () => {
|
||||
const match = findRotRutPayoutSetMatch(
|
||||
makeTransaction({ amount: 3000, description: 'Insättning' }),
|
||||
[A, B],
|
||||
)
|
||||
expect(match).toEqual({ requests: [A], total: 3000, confidence: 0.85, matchMethod: 'amount' })
|
||||
})
|
||||
|
||||
it('matches the sum of several begäran paid in one transfer', () => {
|
||||
const tx = makeTransaction({ amount: 6450.5, description: 'Skatteverket utbetalning' })
|
||||
const match = findRotRutPayoutSetMatch(tx, [A, B, C])
|
||||
expect(match?.requests.map((r) => r.id).sort()).toEqual(['rr-a', 'rr-b', 'rr-c'])
|
||||
expect(match?.total).toBe(6450.5)
|
||||
expect(match?.confidence).toBe(0.9)
|
||||
expect(match?.matchMethod).toBe('amount_sum_skatteverket')
|
||||
})
|
||||
|
||||
it('sums against the beslut amount when one is recorded', () => {
|
||||
const decided = makeRequest('rr-d', 3000, { decided_total: 2500 })
|
||||
expect(findRotRutPayoutSetMatch(makeTransaction({ amount: 4750 }), [decided, B])?.total).toBe(4750)
|
||||
expect(findRotRutPayoutSetMatch(makeTransaction({ amount: 5250 }), [decided, B])).toBeNull()
|
||||
})
|
||||
|
||||
it('still matches when Skatteverket is not named, at lower confidence', () => {
|
||||
const tx = makeTransaction({ amount: 5250, description: 'Insättning' })
|
||||
const match = findRotRutPayoutSetMatch(tx, [A, B])
|
||||
expect(match?.requests.map((r) => r.id).sort()).toEqual(['rr-a', 'rr-b'])
|
||||
expect(match?.confidence).toBe(0.8)
|
||||
expect(match?.matchMethod).toBe('amount_sum')
|
||||
})
|
||||
|
||||
it('never fuzzy-matches: an öre off closes nothing', () => {
|
||||
const tx = makeTransaction({ amount: 5250.01, description: 'Skatteverket' })
|
||||
expect(findRotRutPayoutSetMatch(tx, [A, B])).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses an ambiguous answer: two different sets of the same size close the row', () => {
|
||||
const twin = makeRequest('rr-twin', 3000)
|
||||
// {A, B} and {twin, B} both sum to 5250.
|
||||
expect(findRotRutPayoutSetMatch(makeTransaction({ amount: 5250 }), [A, B, twin])).toBeNull()
|
||||
// With the twin gone the answer is unique again.
|
||||
expect(findRotRutPayoutSetMatch(makeTransaction({ amount: 5250 }), [A, B])).not.toBeNull()
|
||||
})
|
||||
|
||||
it('ignores expenses, non-SEK rows and unmatchable requests', () => {
|
||||
expect(findRotRutPayoutSetMatch(makeTransaction({ amount: -5250 }), [A, B])).toBeNull()
|
||||
expect(
|
||||
findRotRutPayoutSetMatch(makeTransaction({ amount: 5250, currency: 'EUR' as never }), [A, B]),
|
||||
).toBeNull()
|
||||
const settled = makeRequest('rr-a', 3000, { settlement_journal_entry_id: 'je-1' })
|
||||
expect(findRotRutPayoutSetMatch(makeTransaction({ amount: 5250 }), [settled, B])).toBeNull()
|
||||
const cancelled = makeRequest('rr-a', 3000, { status: 'cancelled' })
|
||||
expect(findRotRutPayoutSetMatch(makeTransaction({ amount: 5250 }), [cancelled, B])).toBeNull()
|
||||
})
|
||||
|
||||
it('respects the set-size cap', () => {
|
||||
const tx = makeTransaction({ amount: 6450.5 })
|
||||
expect(findRotRutPayoutSetMatch(tx, [A, B, C], { maxSize: 2 })).toBeNull()
|
||||
expect(findRotRutPayoutSetMatch(tx, [A, B, C], { maxSize: 3 })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchTransactionsToRotRutPayoutSets', () => {
|
||||
it('pairs rows newest first and drains the pool so a begäran is offered once', () => {
|
||||
const rows = [
|
||||
makeTransaction({ id: 'tx-1', amount: 5250, date: '2026-07-12' }),
|
||||
makeTransaction({ id: 'tx-2', amount: 5250, date: '2026-07-10' }),
|
||||
makeTransaction({ id: 'tx-3', amount: 1200.5, date: '2026-07-09' }),
|
||||
]
|
||||
const out = matchTransactionsToRotRutPayoutSets(rows, [A, B, C])
|
||||
expect([...out.keys()]).toEqual(['tx-1', 'tx-3'])
|
||||
expect(out.get('tx-1')?.requests.map((r) => r.id).sort()).toEqual(['rr-a', 'rr-b'])
|
||||
expect(out.get('tx-3')?.requests.map((r) => r.id)).toEqual(['rr-c'])
|
||||
})
|
||||
|
||||
it('skips booked, reviewed and 1:1-hinted rows, and a hinted begäran leaves the pool', () => {
|
||||
const rows = [
|
||||
makeTransaction({ id: 'tx-booked', amount: 5250, journal_entry_id: 'je-9' }),
|
||||
makeTransaction({ id: 'tx-reviewed', amount: 5250, is_business: true }),
|
||||
makeTransaction({
|
||||
id: 'tx-hinted',
|
||||
amount: 3000,
|
||||
potential_rot_rut_payout_request_id: 'rr-a',
|
||||
}),
|
||||
makeTransaction({ id: 'tx-set', amount: 5250 }),
|
||||
makeTransaction({ id: 'tx-rest', amount: 3450.5 }),
|
||||
]
|
||||
const out = matchTransactionsToRotRutPayoutSets(rows, [A, B, C])
|
||||
// A is claimed by the hint, so 5250 (A + B) cannot close; B + C = 3450.5 can.
|
||||
expect([...out.keys()]).toEqual(['tx-rest'])
|
||||
expect(out.get('tx-rest')?.requests.map((r) => r.id).sort()).toEqual(['rr-b', 'rr-c'])
|
||||
})
|
||||
|
||||
it('returns an empty map without rows or requests', () => {
|
||||
expect(matchTransactionsToRotRutPayoutSets([], [A]).size).toBe(0)
|
||||
expect(matchTransactionsToRotRutPayoutSets([makeTransaction({ amount: 3000 })], []).size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,10 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const mockCreatePayoutEntry = vi.fn()
|
||||
const mockCreatePayoutSetEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/rot-rut-entries', () => ({
|
||||
createRotRutPayoutEntry: (...args: unknown[]) => mockCreatePayoutEntry(...args),
|
||||
createRotRutPayoutSetEntry: (...args: unknown[]) => mockCreatePayoutSetEntry(...args),
|
||||
}))
|
||||
|
||||
const mockLogMatchEvent = vi.fn()
|
||||
@@ -24,7 +26,7 @@ vi.mock('@/lib/transactions/inbox-underlag', () => ({
|
||||
propagateUnderlagForBookedTransaction: (...args: unknown[]) => mockPropagateUnderlag(...args),
|
||||
}))
|
||||
|
||||
import { settleRotRutPayoutRequest } from '../rot-rut-settle'
|
||||
import { settleRotRutPayoutRequest, settleRotRutPayoutRequestSet } from '../rot-rut-settle'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
|
||||
const supabase = mockSupabase as unknown as SupabaseClient
|
||||
@@ -319,3 +321,254 @@ describe('settleRotRutPayoutRequest', () => {
|
||||
expect(findCalls('rot_rut_payout_requests', 'update')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bundled payout: several begäran paid in ONE transfer (#2239)
|
||||
// ---------------------------------------------------------------------------
|
||||
const REQUEST_ID_2 = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
function makeSecondRequestRow(overrides: Record<string, unknown> = {}) {
|
||||
return makeRequestRow({
|
||||
id: REQUEST_ID_2,
|
||||
name: 'RUT 2026-07',
|
||||
deduction_type: 'rut',
|
||||
requested_total: 2250,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
describe('settleRotRutPayoutRequestSet', () => {
|
||||
const setParams = {
|
||||
requestIds: [REQUEST_ID, REQUEST_ID_2],
|
||||
paymentDate: '2026-07-10',
|
||||
amount: 5250,
|
||||
bankAccount: '1930',
|
||||
}
|
||||
|
||||
it('returns ROT_RUT_REQUEST_NOT_FOUND naming the begäran that are missing', async () => {
|
||||
enqueue({ data: [makeRequestRow()] })
|
||||
const outcome = await settleRotRutPayoutRequestSet(supabase, 'user-1', 'company-1', setParams)
|
||||
expect(outcome).toEqual({
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_REQUEST_NOT_FOUND',
|
||||
details: { request_ids: [REQUEST_ID_2] },
|
||||
})
|
||||
expect(mockCreatePayoutSetEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses when any begäran is settled or cancelled, before booking anything', async () => {
|
||||
enqueue({ data: [makeRequestRow(), makeSecondRequestRow({ settlement_journal_entry_id: 'je-0' })] })
|
||||
const outcome = await settleRotRutPayoutRequestSet(supabase, 'user-1', 'company-1', setParams)
|
||||
expect(outcome).toMatchObject({
|
||||
ok: false,
|
||||
code: 'ROT_RUT_SETTLE_INVALID_STATE',
|
||||
details: { request_id: REQUEST_ID_2, already_settled: true },
|
||||
})
|
||||
expect(mockCreatePayoutSetEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a transfer that is not the exact sum of the expected payouts', async () => {
|
||||
enqueue({ data: [makeRequestRow(), makeSecondRequestRow()] })
|
||||
const outcome = await settleRotRutPayoutRequestSet(supabase, 'user-1', 'company-1', {
|
||||
...setParams,
|
||||
amount: 5000,
|
||||
})
|
||||
expect(outcome).toEqual({
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_SETTLE_SET_AMOUNT',
|
||||
details: { amount: 5000, expected_total: 5250, request_ids: [REQUEST_ID, REQUEST_ID_2] },
|
||||
})
|
||||
expect(mockCreatePayoutSetEntry).not.toHaveBeenCalled()
|
||||
expect(findCalls('rot_rut_payout_requests', 'update')).toEqual([])
|
||||
})
|
||||
|
||||
it('sums against the recorded beslut, not the requested total', async () => {
|
||||
enqueue({ data: [makeRequestRow({ decided_total: 2500 }), makeSecondRequestRow()] })
|
||||
const outcome = await settleRotRutPayoutRequestSet(supabase, 'user-1', 'company-1', setParams)
|
||||
expect(outcome).toMatchObject({ code: 'ROT_RUT_SETTLE_SET_AMOUNT', details: { expected_total: 4750 } })
|
||||
})
|
||||
|
||||
it('books ONE voucher with a 1513 leg per begäran, marks every begäran paid and links the row', async () => {
|
||||
enqueue({ data: [makeRequestRow(), makeSecondRequestRow()] })
|
||||
mockCreatePayoutSetEntry.mockResolvedValue({ id: 'je-set' })
|
||||
enqueue({
|
||||
data: makeRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-set', decided_total: 3000 }),
|
||||
})
|
||||
enqueue({
|
||||
data: makeSecondRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-set', decided_total: 2250 }),
|
||||
})
|
||||
enqueue({ data: [{ id: TX_ID }] }) // transactions CAS update
|
||||
enqueue({ data: [{ id: 'item-1', requested_amount: 3000 }] }) // items, request 1
|
||||
enqueue({ data: null }) // item mirror
|
||||
enqueue({ data: [] }) // items, request 2
|
||||
|
||||
const outcome = await settleRotRutPayoutRequestSet(supabase, 'user-1', 'company-1', {
|
||||
...setParams,
|
||||
transactionId: TX_ID,
|
||||
})
|
||||
|
||||
expect(outcome).toMatchObject({ ok: true, journalEntryId: 'je-set', amount: 5250 })
|
||||
expect(outcome.ok && outcome.requests.map((r) => r.id)).toEqual([REQUEST_ID, REQUEST_ID_2])
|
||||
expect(mockCreatePayoutSetEntry).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreatePayoutSetEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', {
|
||||
paymentDate: '2026-07-10',
|
||||
bankAccount: '1930',
|
||||
legs: [
|
||||
{ requestId: REQUEST_ID, requestName: 'ROT 2026-07', deductionType: 'rot', amount: 3000 },
|
||||
{ requestId: REQUEST_ID_2, requestName: 'RUT 2026-07', deductionType: 'rut', amount: 2250 },
|
||||
],
|
||||
})
|
||||
// The single-request writer is never used for a bundle.
|
||||
expect(mockCreatePayoutEntry).not.toHaveBeenCalled()
|
||||
|
||||
const requestUpdates = findCalls('rot_rut_payout_requests', 'update').map((c) => c[0])
|
||||
expect(requestUpdates).toHaveLength(2)
|
||||
for (const update of requestUpdates) {
|
||||
expect(update).toMatchObject({ settlement_journal_entry_id: 'je-set', status: 'paid' })
|
||||
}
|
||||
expect(findCalls('rot_rut_payout_requests', 'is')).toEqual([
|
||||
['settlement_journal_entry_id', null],
|
||||
['settlement_journal_entry_id', null],
|
||||
])
|
||||
|
||||
const txUpdate = findCall('transactions', 'update')?.[0] as Record<string, unknown>
|
||||
expect(txUpdate).toMatchObject({
|
||||
journal_entry_id: 'je-set',
|
||||
is_business: true,
|
||||
category: 'income_other',
|
||||
potential_rot_rut_payout_request_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-set',
|
||||
rot_rut_payout_request_ids: [REQUEST_ID, REQUEST_ID_2],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mockClearSuggestions).toHaveBeenCalledTimes(2)
|
||||
expect(mockClearSuggestions).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'rot_rut_payout_request',
|
||||
REQUEST_ID_2,
|
||||
{ exceptTransactionId: TX_ID },
|
||||
)
|
||||
})
|
||||
|
||||
it('reports ROT_RUT_SETTLE_RACE with the begäran that did attach when a later lock loses', async () => {
|
||||
enqueue({ data: [makeRequestRow(), makeSecondRequestRow()] })
|
||||
mockCreatePayoutSetEntry.mockResolvedValue({ id: 'je-set' })
|
||||
enqueue({
|
||||
data: makeRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-set', decided_total: 3000 }),
|
||||
})
|
||||
enqueue({ data: null }) // second CAS matched 0 rows
|
||||
|
||||
const outcome = await settleRotRutPayoutRequestSet(supabase, 'user-1', 'company-1', {
|
||||
...setParams,
|
||||
transactionId: TX_ID,
|
||||
})
|
||||
|
||||
expect(outcome).toEqual({
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_SETTLE_RACE',
|
||||
details: { journal_entry_id: 'je-set', request_id: REQUEST_ID_2, settled_request_ids: [REQUEST_ID] },
|
||||
})
|
||||
// The loser never touches the bank row.
|
||||
expect(findCalls('transactions', 'update')).toEqual([])
|
||||
})
|
||||
|
||||
it('reports ROT_RUT_MATCH_TX_LINK_FAILED when the optimistic lock loses, keeping the voucher', async () => {
|
||||
enqueue({ data: [makeRequestRow(), makeSecondRequestRow()] })
|
||||
mockCreatePayoutSetEntry.mockResolvedValue({ id: 'je-set' })
|
||||
enqueue({ data: makeRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-set' }) })
|
||||
enqueue({ data: makeSecondRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-set' }) })
|
||||
enqueue({ data: [] }) // CAS matched 0 rows: someone booked the row meanwhile
|
||||
|
||||
const outcome = await settleRotRutPayoutRequestSet(supabase, 'user-1', 'company-1', {
|
||||
...setParams,
|
||||
transactionId: TX_ID,
|
||||
})
|
||||
|
||||
expect(outcome).toEqual({
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_MATCH_TX_LINK_FAILED',
|
||||
details: { journal_entry_id: 'je-set', request_ids: [REQUEST_ID, REQUEST_ID_2] },
|
||||
})
|
||||
expect(mockLogMatchEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a partially decided begäran partially_paid and un-mirrored inside a bundle', async () => {
|
||||
// Skatteverket decided 2 500 of the 3 000 requested on the first begäran:
|
||||
// its leg is the beslut, the bundle is 2 500 + 2 250 = 4 750 exactly.
|
||||
enqueue({
|
||||
data: [
|
||||
makeRequestRow({ decided_total: 2500, decided_at: '2026-07-01T00:00:00Z' }),
|
||||
makeSecondRequestRow(),
|
||||
],
|
||||
})
|
||||
mockCreatePayoutSetEntry.mockResolvedValue({ id: 'je-set' })
|
||||
enqueue({
|
||||
data: makeRequestRow({
|
||||
status: 'partially_paid',
|
||||
settlement_journal_entry_id: 'je-set',
|
||||
decided_total: 2500,
|
||||
}),
|
||||
})
|
||||
enqueue({
|
||||
data: makeSecondRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-set', decided_total: 2250 }),
|
||||
})
|
||||
enqueue({ data: [{ id: 'item-2', requested_amount: 2250 }] }) // items, request 2 only
|
||||
enqueue({ data: null }) // item mirror, request 2
|
||||
|
||||
const outcome = await settleRotRutPayoutRequestSet(supabase, 'user-1', 'company-1', {
|
||||
...setParams,
|
||||
amount: 4750,
|
||||
})
|
||||
|
||||
expect(outcome).toMatchObject({ ok: true, journalEntryId: 'je-set', amount: 4750 })
|
||||
expect(outcome.ok && outcome.requests.map((r) => r.status)).toEqual(['partially_paid', 'paid'])
|
||||
expect(mockCreatePayoutSetEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
legs: [
|
||||
expect.objectContaining({ requestId: REQUEST_ID, amount: 2500 }),
|
||||
expect.objectContaining({ requestId: REQUEST_ID_2, amount: 2250 }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
const requestUpdates = findCalls('rot_rut_payout_requests', 'update').map((c) => c[0])
|
||||
expect(requestUpdates).toEqual([
|
||||
{ settlement_journal_entry_id: 'je-set', status: 'partially_paid', decided_total: 2500 },
|
||||
expect.objectContaining({ settlement_journal_entry_id: 'je-set', status: 'paid', decided_total: 2250 }),
|
||||
])
|
||||
// Only the fully paid begäran mirrors requested_amount onto its items.
|
||||
expect(
|
||||
findCalls('rot_rut_payout_request_items', 'eq').filter((c) => c[0] === 'request_id'),
|
||||
).toEqual([['request_id', REQUEST_ID_2]])
|
||||
expect(findCalls('rot_rut_payout_request_items', 'update').map((c) => c[0])).toEqual([
|
||||
{ decided_amount: 2250 },
|
||||
])
|
||||
// Both carry a voucher now, so both retire their sibling hints.
|
||||
expect(mockClearSuggestions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('surfaces an engine failure as a raw error without touching any request', async () => {
|
||||
enqueue({ data: [makeRequestRow(), makeSecondRequestRow()] })
|
||||
mockCreatePayoutSetEntry.mockRejectedValue(new Error('No open fiscal period'))
|
||||
const outcome = await settleRotRutPayoutRequestSet(supabase, 'user-1', 'company-1', setParams)
|
||||
expect(outcome).toMatchObject({ ok: false, kind: 'error', stage: 'book' })
|
||||
expect(findCalls('rot_rut_payout_requests', 'update')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* ROT/RUT payout SET matching: one bank row from Skatteverket that settles
|
||||
* several open begäran at once.
|
||||
*
|
||||
* Skatteverket decides per begäran but pays out everything it decided that
|
||||
* day in ONE transfer, so the bank row often equals no single request and the
|
||||
* 1:1 matcher (rot-rut-payout-matching.ts) stays silent. The candidate here
|
||||
* is the exact covering set: 1..4 open begäran whose expected payouts
|
||||
* (decided_total ?? requested_total) sum to the row to the öre. The search is
|
||||
* the reconciliation module's findExactCoveringSet; this file only decides
|
||||
* what goes into the pool and what comes out.
|
||||
*
|
||||
* Same refusals as the 1:1 matcher: SEK income rows only, unmatchable
|
||||
* requests never take part, and an ambiguous answer (a second, different set
|
||||
* of the same size that also closes the row) yields no suggestion: the
|
||||
* amount alone cannot say which begäran Skatteverket paid, and guessing
|
||||
* would clear the wrong 1513 fordran. There is no fuzzy pass on purpose:
|
||||
* Skatteverket pays exactly the decided sums.
|
||||
*
|
||||
* No hint column for a set. The inbox page, the worklist and the ingest path
|
||||
* recompute it from the open pool (a handful of rows per company), the way
|
||||
* expense-payout suggestions already are; a persisted uuid[] would need every
|
||||
* clear path the single hint has and could still go stale (#1259).
|
||||
*
|
||||
* Pure and client-safe on purpose: the inbox page runs it in the browser.
|
||||
*/
|
||||
|
||||
import { findExactCoveringSet } from '@/lib/reconciliation/covering-set'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import {
|
||||
expectedRotRutPayoutAmount,
|
||||
isMatchableRotRutPayoutRequest,
|
||||
mentionsSkatteverket,
|
||||
type RotRutPayoutRequestCandidate,
|
||||
} from './rot-rut-payout-matching'
|
||||
|
||||
/** Largest set considered. Skatteverket rarely bundles more decisions per transfer. */
|
||||
const DEFAULT_MAX_SET_SIZE = 4
|
||||
|
||||
export interface RotRutPayoutSetMatch<
|
||||
T extends RotRutPayoutRequestCandidate = RotRutPayoutRequestCandidate,
|
||||
> {
|
||||
/** The begäran the row settles, in the order they are booked (largest first). */
|
||||
requests: T[]
|
||||
/** Sum of the requests' expected payouts: equals the row amount to the öre. */
|
||||
total: number
|
||||
confidence: number
|
||||
matchMethod: 'amount_skatteverket' | 'amount' | 'amount_sum_skatteverket' | 'amount_sum'
|
||||
}
|
||||
|
||||
export interface RotRutSetMatchableTransaction {
|
||||
amount: number
|
||||
currency?: string | null
|
||||
description?: string | null
|
||||
merchant_name?: string | null
|
||||
}
|
||||
|
||||
export interface RotRutSetMatchOptions {
|
||||
/** Largest set considered. Default 4. */
|
||||
maxSize?: number
|
||||
}
|
||||
|
||||
interface PoolEntry<T extends RotRutPayoutRequestCandidate> {
|
||||
id: string
|
||||
amount: number
|
||||
dateDistanceDays: number
|
||||
request: T
|
||||
}
|
||||
|
||||
function toPool<T extends RotRutPayoutRequestCandidate>(requests: T[]): PoolEntry<T>[] {
|
||||
return requests
|
||||
.filter((request) => isMatchableRotRutPayoutRequest(request))
|
||||
.map((request) => ({
|
||||
id: request.id,
|
||||
amount: expectedRotRutPayoutAmount(request),
|
||||
// No payout-date signal on a begäran: equal-size sets rank by amount,
|
||||
// then id (findExactCoveringSet's own deterministic order).
|
||||
dateDistanceDays: 0,
|
||||
request,
|
||||
}))
|
||||
}
|
||||
|
||||
function searchSet<T extends RotRutPayoutRequestCandidate>(
|
||||
target: number,
|
||||
pool: PoolEntry<T>[],
|
||||
maxSize: number,
|
||||
): PoolEntry<T>[] | null {
|
||||
return findExactCoveringSet(target, pool, { maxSize, maxCandidates: pool.length })
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the open begäran (1..maxSize of them) whose expected payouts sum to
|
||||
* an income transaction, or null. Pure: the caller loads the open requests
|
||||
* once.
|
||||
*/
|
||||
export function findRotRutPayoutSetMatch<T extends RotRutPayoutRequestCandidate>(
|
||||
transaction: RotRutSetMatchableTransaction,
|
||||
openRequests: T[],
|
||||
options: RotRutSetMatchOptions = {},
|
||||
): RotRutPayoutSetMatch<T> | 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 maxSize = Math.max(1, options.maxSize ?? DEFAULT_MAX_SET_SIZE)
|
||||
const target = roundOre(transaction.amount)
|
||||
const pool = toPool(openRequests)
|
||||
if (pool.length === 0) return null
|
||||
|
||||
const best = searchSet(target, pool, maxSize)
|
||||
if (!best) return null
|
||||
|
||||
// Ambiguity: any OTHER set of the same size that also closes the row must
|
||||
// omit at least one member of `best`, so dropping each member in turn and
|
||||
// searching again finds it if it exists. Sizes below |best| cannot appear
|
||||
// (the search returns the smallest set first), so a hit is a genuine tie.
|
||||
for (const member of best) {
|
||||
const without = pool.filter((entry) => entry.id !== member.id)
|
||||
if (searchSet(target, without, best.length)) return null
|
||||
}
|
||||
|
||||
const named = mentionsSkatteverket(transaction)
|
||||
const single = best.length === 1
|
||||
return {
|
||||
requests: best.map((entry) => entry.request),
|
||||
total: roundOre(best.reduce((sum, entry) => sum + entry.amount, 0)),
|
||||
// A sum over several begäran is a weaker signal than one exact amount:
|
||||
// one notch below the 1:1 ladder (0.95 / 0.85).
|
||||
confidence: single ? (named ? 0.95 : 0.85) : named ? 0.9 : 0.8,
|
||||
matchMethod: single
|
||||
? named
|
||||
? 'amount_skatteverket'
|
||||
: 'amount'
|
||||
: named
|
||||
? 'amount_sum_skatteverket'
|
||||
: 'amount_sum',
|
||||
}
|
||||
}
|
||||
|
||||
export interface RotRutSetMatchableRow extends RotRutSetMatchableTransaction {
|
||||
id: string
|
||||
is_business?: boolean | null
|
||||
journal_entry_id?: string | null
|
||||
/**
|
||||
* A persisted 1:1 hint (rot-rut-payout-matching.ts) wins for its row and
|
||||
* takes its begäran out of the pool, so a set never offers a request that
|
||||
* another row is already about to settle.
|
||||
*/
|
||||
potential_rot_rut_payout_request_id?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair unbooked SEK income rows with the set of open begäran they settle.
|
||||
* Rows are walked in the order given (callers pass newest first); every set
|
||||
* handed out drains the pool, mirroring the one-payout-per-begäran rule at
|
||||
* bank ingest. Rows that are booked, already reviewed (is_business set) or
|
||||
* carrying a persisted 1:1 hint are skipped: they have their own path.
|
||||
*/
|
||||
export function matchTransactionsToRotRutPayoutSets<T extends RotRutPayoutRequestCandidate>(
|
||||
transactions: RotRutSetMatchableRow[],
|
||||
openRequests: T[],
|
||||
options: RotRutSetMatchOptions = {},
|
||||
): Map<string, RotRutPayoutSetMatch<T>> {
|
||||
const out = new Map<string, RotRutPayoutSetMatch<T>>()
|
||||
if (transactions.length === 0 || openRequests.length === 0) return out
|
||||
|
||||
const claimed = new Set<string>()
|
||||
for (const tx of transactions) {
|
||||
if (tx.potential_rot_rut_payout_request_id) claimed.add(tx.potential_rot_rut_payout_request_id)
|
||||
}
|
||||
let pool = openRequests.filter((request) => !claimed.has(request.id))
|
||||
|
||||
for (const tx of transactions) {
|
||||
if (pool.length === 0) break
|
||||
if (tx.journal_entry_id) continue
|
||||
if (tx.is_business !== null && tx.is_business !== undefined) continue
|
||||
if (tx.potential_rot_rut_payout_request_id) continue
|
||||
const match = findRotRutPayoutSetMatch(tx, pool, options)
|
||||
if (!match) continue
|
||||
out.set(tx.id, match)
|
||||
const taken = new Set(match.requests.map((request) => request.id))
|
||||
pool = pool.filter((request) => !taken.has(request.id))
|
||||
}
|
||||
return out
|
||||
}
|
||||
+397
-110
@@ -12,12 +12,21 @@
|
||||
* (bank-row match: amount/date/bank account come from the transaction,
|
||||
* and the row is linked to the settlement voucher in the same call)
|
||||
*
|
||||
* Skatteverket decides per begäran but pays everything it decided that day
|
||||
* in ONE transfer (#2239). settleRotRutPayoutRequestSet books that bundle as
|
||||
* one voucher (one bank leg, one 1513 leg per begäran) and marks every
|
||||
* begäran settled by it, through the same writer and the same tail as the
|
||||
* single path; a bundle is always booked at exactly the decided sums.
|
||||
*
|
||||
* 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 {
|
||||
createRotRutPayoutEntry,
|
||||
createRotRutPayoutSetEntry,
|
||||
} 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'
|
||||
@@ -66,6 +75,7 @@ export type SettleRotRutPayoutErrorCode =
|
||||
| 'ROT_RUT_REQUEST_NOT_FOUND'
|
||||
| 'ROT_RUT_SETTLE_INVALID_STATE'
|
||||
| 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS'
|
||||
| 'ROT_RUT_SETTLE_SET_AMOUNT'
|
||||
| 'ROT_RUT_SETTLE_RACE'
|
||||
| 'ROT_RUT_MATCH_TX_LINK_FAILED'
|
||||
|
||||
@@ -81,6 +91,157 @@ export type SettleRotRutPayoutOutcome =
|
||||
/** A raw Supabase/engine error the route maps through errorResponse(). */
|
||||
| { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update' }
|
||||
|
||||
const SETTLED_REQUEST_COLUMNS =
|
||||
'id, name, deduction_type, status, requested_total, decided_total, decided_at, settlement_journal_entry_id'
|
||||
|
||||
interface PayoutRequestRow {
|
||||
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
|
||||
}
|
||||
|
||||
/** Mirrors the settle guard: no settlement voucher yet and not cancelled/rejected. */
|
||||
function isSettleable(request: PayoutRequestRow): boolean {
|
||||
return (
|
||||
!request.settlement_journal_entry_id && !['cancelled', 'rejected'].includes(request.status)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. Returns the updated row, null when the lock
|
||||
* was lost, or the raw error.
|
||||
*/
|
||||
async function attachSettlementVoucher(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
request: PayoutRequestRow,
|
||||
journalEntryId: string,
|
||||
amount: number,
|
||||
fullyPaid: boolean,
|
||||
): Promise<{ updated: SettledRotRutPayoutRequest | null; error: unknown }> {
|
||||
const update: Record<string, unknown> = {
|
||||
settlement_journal_entry_id: journalEntryId,
|
||||
status: fullyPaid ? 'paid' : 'partially_paid',
|
||||
decided_total: request.decided_total ?? amount,
|
||||
}
|
||||
if (!request.decided_at) {
|
||||
update.decided_at = new Date().toISOString()
|
||||
}
|
||||
const { data: updated, error } = await supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.update(update)
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', request.id)
|
||||
.is('settlement_journal_entry_id', null)
|
||||
.select(SETTLED_REQUEST_COLUMNS)
|
||||
.maybeSingle()
|
||||
return { updated: (updated as SettledRotRutPayoutRequest | null) ?? null, error }
|
||||
}
|
||||
|
||||
/**
|
||||
* Link the bank row to the settlement voucher with an optimistic lock on the
|
||||
* pointer the route read (null for a free row, or the stale id of a reversed
|
||||
* entry, issue #988). 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). Returns false
|
||||
* when the link did not land; the voucher stands either way.
|
||||
*/
|
||||
async function linkTransactionToSettlement(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: {
|
||||
transactionId: string
|
||||
previousJournalEntryId: string | null
|
||||
journalEntryId: string
|
||||
logState: Record<string, unknown>
|
||||
logContext: Record<string, unknown>
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const txUpdate = supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id: params.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 (params.previousJournalEntryId === null
|
||||
? txUpdate.is('journal_entry_id', null)
|
||||
: txUpdate.eq('journal_entry_id', params.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, {
|
||||
...params.logContext,
|
||||
journalEntryId: params.journalEntryId,
|
||||
transactionId: params.transactionId,
|
||||
reason: linkError?.message ?? 'optimistic lock returned 0 rows',
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// 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,
|
||||
params.journalEntryId,
|
||||
)
|
||||
|
||||
await logMatchEvent(supabase, userId, params.transactionId, 'matched', {
|
||||
matchConfidence: 1.0,
|
||||
matchMethod: 'rot_rut_payout_manual_confirm',
|
||||
newState: { journal_entry_id: params.journalEntryId, ...params.logState },
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/** A fully paid begäran mirrors requested_amount onto every item's decided_amount. */
|
||||
async function mirrorDecidedAmounts(supabase: SupabaseClient, requestId: string): Promise<void> {
|
||||
const { data: items, error: itemsFetchError } = await supabase
|
||||
.from('rot_rut_payout_request_items')
|
||||
.select('id, requested_amount')
|
||||
.eq('request_id', requestId)
|
||||
if (itemsFetchError) {
|
||||
log.warn('failed to fetch items for decided_amount mirror', {
|
||||
payoutRequestId: 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function settleRotRutPayoutRequest(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
@@ -101,10 +262,7 @@ export async function settleRotRutPayoutRequest(
|
||||
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) {
|
||||
if (!isSettleable(payoutRequest)) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
@@ -140,7 +298,8 @@ export async function settleRotRutPayoutRequest(
|
||||
// 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.
|
||||
// Two begäran in one transfer is the set path (settleRotRutPayoutRequestSet);
|
||||
// anything else the user books another way. This path stays exact.
|
||||
const expectedAmount = roundOre(Number(payoutRequest.decided_total ?? payoutRequest.requested_total))
|
||||
if (amount > expectedAmount + 0.005) {
|
||||
return {
|
||||
@@ -168,40 +327,27 @@ export async function settleRotRutPayoutRequest(
|
||||
}
|
||||
|
||||
const fullyPaid = amount >= Number(payoutRequest.requested_total)
|
||||
const update: Record<string, unknown> = {
|
||||
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()
|
||||
const { updated, error: updateError } = await attachSettlementVoucher(
|
||||
supabase,
|
||||
companyId,
|
||||
payoutRequest,
|
||||
journalEntryId,
|
||||
amount,
|
||||
fullyPaid,
|
||||
)
|
||||
|
||||
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, {
|
||||
log.error('rot/rut payout entry booked but request update failed', updateError as Error, {
|
||||
journalEntryId,
|
||||
payoutRequestId: params.requestId,
|
||||
})
|
||||
return { ok: false, kind: 'error', error: updateError, stage: 'update' }
|
||||
}
|
||||
if (!updated) {
|
||||
// The loser's voucher already exists (immutable per BFL): say so loudly
|
||||
// rather than overwrite the winner.
|
||||
log.error('rot/rut payout entry booked but request was settled concurrently', undefined, {
|
||||
journalEntryId,
|
||||
payoutRequestId: params.requestId,
|
||||
@@ -215,43 +361,18 @@ export async function settleRotRutPayoutRequest(
|
||||
}
|
||||
|
||||
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',
|
||||
})
|
||||
const linked = await linkTransactionToSettlement(supabase, userId, companyId, {
|
||||
transactionId: params.transactionId,
|
||||
previousJournalEntryId: params.previousJournalEntryId ?? null,
|
||||
journalEntryId,
|
||||
logState: {
|
||||
rot_rut_payout_request_id: params.requestId,
|
||||
request_status: updated.status,
|
||||
amount,
|
||||
},
|
||||
logContext: { payoutRequestId: params.requestId },
|
||||
})
|
||||
if (!linked) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
@@ -259,51 +380,10 @@ export async function settleRotRutPayoutRequest(
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
await mirrorDecidedAmounts(supabase, params.requestId)
|
||||
}
|
||||
|
||||
// The request is settled: every OTHER bank row still hinting at it is a dead
|
||||
@@ -327,9 +407,216 @@ export async function settleRotRutPayoutRequest(
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
request: updated as SettledRotRutPayoutRequest,
|
||||
request: updated,
|
||||
journalEntryId,
|
||||
amount,
|
||||
fullyPaid,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettleRotRutPayoutSetParams {
|
||||
/** The begäran Skatteverket paid together, in booking order. */
|
||||
requestIds: string[]
|
||||
paymentDate: string
|
||||
/** The bank row amount: must equal the requests' expected payouts to the öre. */
|
||||
amount: number
|
||||
/** BAS 19xx account the payout landed on. Defaults to 1930 in the engine. */
|
||||
bankAccount?: string
|
||||
/** See SettleRotRutPayoutParams.transactionId. */
|
||||
transactionId?: string
|
||||
/** See SettleRotRutPayoutParams.previousJournalEntryId. */
|
||||
previousJournalEntryId?: string | null
|
||||
}
|
||||
|
||||
export type SettleRotRutPayoutSetOutcome =
|
||||
| {
|
||||
ok: true
|
||||
requests: SettledRotRutPayoutRequest[]
|
||||
journalEntryId: string
|
||||
amount: number
|
||||
}
|
||||
| { ok: false; kind: 'code'; code: SettleRotRutPayoutErrorCode; details?: Record<string, unknown> }
|
||||
| { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update' }
|
||||
|
||||
/**
|
||||
* Settle several begäran with ONE bank transfer: one voucher (debit 19xx for
|
||||
* the transfer, one 1513 credit per begäran), every request pointed at it,
|
||||
* the row linked once. Refuses before booking anything unless every request
|
||||
* is open and unsettled and the expected payouts sum to the transfer
|
||||
* exactly: Skatteverket pays the decided sums, so a bundle never books
|
||||
* anything but decided_total ?? requested_total per begäran. A begäran
|
||||
* Skatteverket decided at less than requested is a legitimate member (its
|
||||
* leg is the beslut) and ends up partially_paid with its items untouched,
|
||||
* exactly as the single path leaves it for manual handling; the others
|
||||
* complete as paid.
|
||||
*/
|
||||
export async function settleRotRutPayoutRequestSet(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: SettleRotRutPayoutSetParams,
|
||||
): Promise<SettleRotRutPayoutSetOutcome> {
|
||||
const requestIds = [...new Set(params.requestIds)]
|
||||
if (requestIds.length === 0) {
|
||||
return { ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' }
|
||||
}
|
||||
|
||||
const { data: rows, error: fetchError } = await supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', requestIds)
|
||||
if (fetchError) {
|
||||
return { ok: false, kind: 'error', error: fetchError, stage: 'fetch' }
|
||||
}
|
||||
const byId = new Map(((rows ?? []) as PayoutRequestRow[]).map((row) => [row.id, row] as const))
|
||||
const missing = requestIds.filter((id) => !byId.has(id))
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_REQUEST_NOT_FOUND',
|
||||
details: { request_ids: missing },
|
||||
}
|
||||
}
|
||||
// Booking order follows the caller's order (largest first from the matcher).
|
||||
const requests = requestIds.map((id) => byId.get(id)!)
|
||||
|
||||
const blocked = requests.find((request) => !isSettleable(request))
|
||||
if (blocked) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_SETTLE_INVALID_STATE',
|
||||
details: {
|
||||
request_id: blocked.id,
|
||||
status: blocked.status,
|
||||
already_settled: !!blocked.settlement_journal_entry_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const legs = requests.map((request) => {
|
||||
const amount = roundOre(Number(request.decided_total ?? request.requested_total))
|
||||
return {
|
||||
request,
|
||||
amount,
|
||||
// Same rule as the single path: only a leg covering the requested total
|
||||
// completes the begäran; a recorded lower beslut stays partially_paid.
|
||||
fullyPaid: amount >= Number(request.requested_total),
|
||||
}
|
||||
})
|
||||
const expectedTotal = roundOre(legs.reduce((sum, leg) => sum + leg.amount, 0))
|
||||
const amount = roundOre(params.amount)
|
||||
if (Math.abs(amount - expectedTotal) > 0.005) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_SETTLE_SET_AMOUNT',
|
||||
details: { amount, expected_total: expectedTotal, request_ids: requestIds },
|
||||
}
|
||||
}
|
||||
|
||||
// The voucher is the accounting record: engine failure must block.
|
||||
let journalEntryId: string
|
||||
try {
|
||||
const entry = await createRotRutPayoutSetEntry(supabase, companyId, userId, {
|
||||
paymentDate: params.paymentDate,
|
||||
bankAccount: params.bankAccount,
|
||||
legs: legs.map((leg) => ({
|
||||
requestId: leg.request.id,
|
||||
requestName: leg.request.name,
|
||||
deductionType: leg.request.deduction_type,
|
||||
amount: leg.amount,
|
||||
})),
|
||||
})
|
||||
journalEntryId = entry.id
|
||||
} catch (engineError) {
|
||||
return { ok: false, kind: 'error', error: engineError, stage: 'book' }
|
||||
}
|
||||
|
||||
// Every request absorbs the same voucher under its own CAS. A lost lock
|
||||
// means that begäran was settled concurrently by another voucher: this
|
||||
// voucher then credits 1513 once too often for it. Say so loudly (the
|
||||
// details name the requests that did attach); never unbook.
|
||||
const settled: SettledRotRutPayoutRequest[] = []
|
||||
for (const leg of legs) {
|
||||
const { updated, error: updateError } = await attachSettlementVoucher(
|
||||
supabase,
|
||||
companyId,
|
||||
leg.request,
|
||||
journalEntryId,
|
||||
leg.amount,
|
||||
leg.fullyPaid,
|
||||
)
|
||||
if (updateError) {
|
||||
log.error('rot/rut payout set entry booked but request update failed', updateError as Error, {
|
||||
journalEntryId,
|
||||
payoutRequestId: leg.request.id,
|
||||
settledRequestIds: settled.map((request) => request.id),
|
||||
})
|
||||
return { ok: false, kind: 'error', error: updateError, stage: 'update' }
|
||||
}
|
||||
if (!updated) {
|
||||
log.error('rot/rut payout set entry booked but a request was settled concurrently', undefined, {
|
||||
journalEntryId,
|
||||
payoutRequestId: leg.request.id,
|
||||
settledRequestIds: settled.map((request) => request.id),
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_SETTLE_RACE',
|
||||
details: {
|
||||
journal_entry_id: journalEntryId,
|
||||
request_id: leg.request.id,
|
||||
settled_request_ids: settled.map((request) => request.id),
|
||||
},
|
||||
}
|
||||
}
|
||||
settled.push(updated)
|
||||
}
|
||||
|
||||
if (params.transactionId) {
|
||||
const linked = await linkTransactionToSettlement(supabase, userId, companyId, {
|
||||
transactionId: params.transactionId,
|
||||
previousJournalEntryId: params.previousJournalEntryId ?? null,
|
||||
journalEntryId,
|
||||
logState: {
|
||||
rot_rut_payout_request_ids: requestIds,
|
||||
request_statuses: settled.map((request) => request.status),
|
||||
amount,
|
||||
},
|
||||
logContext: { payoutRequestIds: requestIds },
|
||||
})
|
||||
if (!linked) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_MATCH_TX_LINK_FAILED',
|
||||
details: { journal_entry_id: journalEntryId, request_ids: requestIds },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every settled begäran carries a voucher now and is no longer matchable,
|
||||
// so its sibling hints die either way; the item mirror is for the fully
|
||||
// paid ones only (a partial beslut keeps its item amounts for the manual
|
||||
// follow-up, as on the single path).
|
||||
for (const leg of legs) {
|
||||
if (leg.fullyPaid) await mirrorDecidedAmounts(supabase, leg.request.id)
|
||||
await clearSettledInvoiceSuggestions(supabase, companyId, 'rot_rut_payout_request', leg.request.id, {
|
||||
exceptTransactionId: params.transactionId ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
log.info('rot/rut payout set settled', {
|
||||
userId,
|
||||
payoutRequestIds: requestIds,
|
||||
journalEntryId,
|
||||
amount,
|
||||
transactionId: params.transactionId ?? null,
|
||||
})
|
||||
|
||||
return { ok: true, requests: settled, journalEntryId, amount }
|
||||
}
|
||||
|
||||
@@ -1812,6 +1812,53 @@ describe('ingestTransactions', () => {
|
||||
expect(mockEvaluateMappingRules).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4a'. Skatteverket bundles the beslut it pays that day into ONE transfer
|
||||
// (#2239): a row equal to the SUM of several open begäran gets no hint
|
||||
// column (the inbox recomputes the set) but must still skip the mapping
|
||||
// engine and drain the pool, exactly like a 1:1 hint.
|
||||
// -----------------------------------------------------------------------
|
||||
it('recognises a bundled ROT/RUT payout (sum of several begäran) without persisting a hint', async () => {
|
||||
const { supabase, enqueue, updates } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: 5250, description: 'Skatteverket utbetalning' })
|
||||
const inserted = makeTransaction({
|
||||
id: 'tx-skv-set',
|
||||
amount: 5250,
|
||||
currency: 'SEK',
|
||||
description: 'Skatteverket utbetalning',
|
||||
external_id: raw.external_id,
|
||||
})
|
||||
const request = (id: string, name: string, requestedTotal: number) => ({
|
||||
id,
|
||||
name,
|
||||
deduction_type: 'rot' as const,
|
||||
status: 'submitted',
|
||||
requested_total: requestedTotal,
|
||||
decided_total: null,
|
||||
settlement_journal_entry_id: null,
|
||||
})
|
||||
mockLoadOpenRotRutPayoutRequests.mockResolvedValueOnce([
|
||||
request('rr-1', 'ROT 2026-07', 3000),
|
||||
request('rr-2', 'ROT 2026-08', 2250),
|
||||
])
|
||||
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
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.auto_matched_invoices).toBe(1)
|
||||
// No single begäran equals the row, so no 1:1 hint is written; the set is
|
||||
// recomputed at read time instead of persisted.
|
||||
expect(updates['transactions'] ?? []).toEqual([])
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type RotRutPayoutRequestCandidate,
|
||||
} from '@/lib/invoices/rot-rut-payout-matching'
|
||||
import { loadOpenRotRutPayoutRequests } from '@/lib/invoices/rot-rut-payout-candidates'
|
||||
import { findRotRutPayoutSetMatch } from '@/lib/invoices/rot-rut-payout-set-matching'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
@@ -1124,6 +1125,29 @@ export async function ingestTransactions(
|
||||
result.auto_matched_invoices++
|
||||
continue
|
||||
}
|
||||
|
||||
// 3d. No single begäran equals the row: Skatteverket bundles the
|
||||
// beslut it pays that day into one transfer, so try the exact
|
||||
// covering set (#2239). A set has no hint column (the inbox and the
|
||||
// worklist recompute it from the open pool), but the row must still
|
||||
// skip the mapping engine and drain the pool exactly like a 1:1 hint.
|
||||
if (!match) {
|
||||
const set = findRotRutPayoutSetMatch(newTransaction as Transaction, openRotRutPayoutRequests)
|
||||
if (set && set.requests.length > 1) {
|
||||
const requestIds = set.requests.map((req) => req.id)
|
||||
logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', {
|
||||
matchConfidence: set.confidence,
|
||||
matchMethod: set.matchMethod,
|
||||
newState: { rot_rut_payout_request_ids: requestIds },
|
||||
})
|
||||
for (const id of requestIds) matchedRotRutRequestIds.add(id)
|
||||
openRotRutPayoutRequests = openRotRutPayoutRequests.filter(
|
||||
(req) => !requestIds.includes(req.id),
|
||||
)
|
||||
result.auto_matched_invoices++
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: continue processing
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
+12
-3
@@ -2971,7 +2971,8 @@
|
||||
"move_account_currency_mismatch": "Different currency ({currency})",
|
||||
"move_account_cancel": "Cancel",
|
||||
"move_account_save": "Move",
|
||||
"method_line": "Payment method: {method}"
|
||||
"method_line": "Payment method: {method}",
|
||||
"match_rot_rut_payout_set_btn": "Match ROT/RUT payout ({count} requests)"
|
||||
},
|
||||
"tx_method": {
|
||||
"card": "Card purchase",
|
||||
@@ -3222,7 +3223,14 @@
|
||||
"on_confirm_voucher": "A journal entry is created automatically",
|
||||
"cancel": "Cancel",
|
||||
"confirming": "Confirming...",
|
||||
"confirm": "Confirm match"
|
||||
"confirm": "Confirm match",
|
||||
"requests_label": "Payout requests ({count})",
|
||||
"requests_total_label": "Requests total",
|
||||
"amounts_match_set": "The amount matches the sum of the requests",
|
||||
"amounts_differ_set": "The amount differs from the sum of the requests",
|
||||
"set_exact_required": "Several requests can only be booked together when the transaction equals the sum of their decided amounts exactly. Match them one at a time or book the transaction another way.",
|
||||
"booking_receivable_line_named": "1513 Receivable, split invoice ({name})",
|
||||
"on_confirm_requests": "All {count} requests are marked as paid out"
|
||||
},
|
||||
"tx_invoice_picker": {
|
||||
"loading": "Loading invoices...",
|
||||
@@ -6249,7 +6257,8 @@
|
||||
"review_rerun_done_description": "{applied, plural, one {# transaction was linked automatically} other {# transactions were linked automatically}}, {suggested, plural, one {# new suggestion to review} other {# new suggestions to review}}.",
|
||||
"footer_to_handle": "{count, plural, =0 {Nothing to handle} =1 {1 to handle} other {# to handle}}",
|
||||
"recon_attn": "{count, plural, one {1 unbooked bank transaction: the bank reconciliation can find automatic matches against existing vouchers.} other {# unbooked bank transactions: the bank reconciliation can find automatic matches against existing vouchers.}}",
|
||||
"recon_attn_action": "Preview matches"
|
||||
"recon_attn_action": "Preview matches",
|
||||
"rot_rut_payout_matched_set_description": "The payout from Skatteverket was booked against {count} requests: {names}"
|
||||
},
|
||||
"bookkeeping": {
|
||||
"toast_post_failed": "Could not post",
|
||||
|
||||
+12
-3
@@ -2971,7 +2971,8 @@
|
||||
"move_account_currency_mismatch": "Annan valuta ({currency})",
|
||||
"move_account_cancel": "Avbryt",
|
||||
"move_account_save": "Flytta",
|
||||
"method_line": "Betalsätt: {method}"
|
||||
"method_line": "Betalsätt: {method}",
|
||||
"match_rot_rut_payout_set_btn": "Matcha ROT/RUT-utbetalning ({count} begäran)"
|
||||
},
|
||||
"tx_method": {
|
||||
"card": "Kortköp",
|
||||
@@ -3222,7 +3223,14 @@
|
||||
"on_confirm_voucher": "Bokföringsverifikation skapas automatiskt",
|
||||
"cancel": "Avbryt",
|
||||
"confirming": "Bekräftar...",
|
||||
"confirm": "Bekräfta matchning"
|
||||
"confirm": "Bekräfta matchning",
|
||||
"requests_label": "Begäran om utbetalning ({count} st)",
|
||||
"requests_total_label": "Summa begäran",
|
||||
"amounts_match_set": "Beloppet stämmer med summan av begäran",
|
||||
"amounts_differ_set": "Beloppet skiljer sig från summan av begäran",
|
||||
"set_exact_required": "Flera begäran kan bara bokföras tillsammans när transaktionen exakt motsvarar summan av deras beslutade belopp. Matcha dem en i taget eller bokför transaktionen på annat sätt.",
|
||||
"booking_receivable_line_named": "1513 Kundfordringar delad faktura ({name})",
|
||||
"on_confirm_requests": "Alla {count} begäran markeras som utbetalda"
|
||||
},
|
||||
"tx_invoice_picker": {
|
||||
"loading": "Laddar fakturor...",
|
||||
@@ -6249,7 +6257,8 @@
|
||||
"review_rerun_done_description": "{applied, plural, one {# transaktion kopplades automatiskt} other {# transaktioner kopplades automatiskt}}, {suggested, plural, one {# nytt förslag att granska} other {# nya förslag att granska}}.",
|
||||
"footer_to_handle": "{count, plural, =0 {Inget att hantera} =1 {1 att hantera} other {# att hantera}}",
|
||||
"recon_attn": "{count, plural, one {1 obokförd banktransaktion: bankavstämningen kan hitta automatiska träffar mot befintliga verifikationer.} other {# obokförda banktransaktioner: bankavstämningen kan hitta automatiska träffar mot befintliga verifikationer.}}",
|
||||
"recon_attn_action": "Förhandsgranska träffar"
|
||||
"recon_attn_action": "Förhandsgranska träffar",
|
||||
"rot_rut_payout_matched_set_description": "Utbetalningen från Skatteverket bokfördes mot {count} begäran: {names}"
|
||||
},
|
||||
"bookkeeping": {
|
||||
"toast_post_failed": "Kunde inte bokföra",
|
||||
|
||||
Reference in New Issue
Block a user