Files
accounted/lib/invoices/rot-rut-settle.ts
T
MattssonandClaude Fable 5.1 50b6299699 feat(rot-rut): match Skatteverket's payout against the begäran from the bank row (#2271)
* feat(rot-rut): match Skatteverket's payout against the begäran from the bank row

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

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

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

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

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

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

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

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

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

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

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:33:41 +02:00

336 lines
12 KiB
TypeScript

/**
* Settle a ROT/RUT begäran: book Skatteverkets utbetalning and, optionally,
* link the bank transaction that carried it.
*
* Debit 19xx bank account (default 1930) [amount]
* Credit 1513 Skattereduktion rot/rut [amount]
*
* Shared between two callers:
* - REST: app/api/rot-rut/payout-requests/[id]/settle/route.ts
* (headless settle: amount/date/bank account supplied by the caller)
* - REST: app/api/transactions/[id]/match-rot-rut-payout/route.ts
* (bank-row match: amount/date/bank account come from the transaction,
* and the row is linked to the settlement voucher in the same call)
*
* The journal entry IS the accounting record: engine failure blocks the whole
* operation. Everything after the voucher is best-effort-with-loud-logging,
* never an unbook (the voucher is immutable per BFL).
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createRotRutPayoutEntry } from '@/lib/bookkeeping/rot-rut-entries'
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
import { roundOre } from '@/lib/money'
import { createLogger } from '@/lib/logger'
const log = createLogger('invoices/rot-rut-settle')
export interface SettleRotRutPayoutParams {
requestId: string
paymentDate: string
/** Defaults to decided_total ?? requested_total. */
amount?: number
/** BAS 19xx account the payout landed on. Defaults to 1930 in the engine. */
bankAccount?: string
/**
* Bank transaction that carried the payout. When set, the row is linked to
* the settlement voucher (journal_entry_id) and its match hints are cleared.
* The caller must have verified the row is unbooked and belongs to the
* company; this function re-checks with an optimistic lock on
* journal_entry_id IS NULL.
*/
transactionId?: string
/**
* The transaction's journal_entry_id as the caller read it: null for a free
* row, or the STALE id of a reversed/cancelled entry the route judged not
* live (issue #988). The link CAS locks on exactly that value, so a stale
* pointer can be overwritten while a concurrent live link still turns the
* write into a no-op (same contract as link-journal-entry.ts).
*/
previousJournalEntryId?: string | null
}
export interface SettledRotRutPayoutRequest {
id: string
name: string
deduction_type: 'rot' | 'rut'
status: string
requested_total: number | string
decided_total: number | string | null
decided_at: string | null
settlement_journal_entry_id: string | null
}
export type SettleRotRutPayoutErrorCode =
| 'ROT_RUT_REQUEST_NOT_FOUND'
| 'ROT_RUT_SETTLE_INVALID_STATE'
| 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS'
| 'ROT_RUT_SETTLE_RACE'
| 'ROT_RUT_MATCH_TX_LINK_FAILED'
export type SettleRotRutPayoutOutcome =
| {
ok: true
request: SettledRotRutPayoutRequest
journalEntryId: string
amount: number
fullyPaid: boolean
}
| { ok: false; kind: 'code'; code: SettleRotRutPayoutErrorCode; details?: Record<string, unknown> }
/** A raw Supabase/engine error the route maps through errorResponse(). */
| { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update' }
export async function settleRotRutPayoutRequest(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: SettleRotRutPayoutParams,
): Promise<SettleRotRutPayoutOutcome> {
const { data: payoutRequest, error: fetchError } = await supabase
.from('rot_rut_payout_requests')
.select('*')
.eq('company_id', companyId)
.eq('id', params.requestId)
.maybeSingle()
if (fetchError) {
return { ok: false, kind: 'error', error: fetchError, stage: 'fetch' }
}
if (!payoutRequest) {
return { ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' }
}
const settleable =
!payoutRequest.settlement_journal_entry_id &&
!['cancelled', 'rejected'].includes(payoutRequest.status)
if (!settleable) {
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_SETTLE_INVALID_STATE',
details: {
status: payoutRequest.status,
already_settled: !!payoutRequest.settlement_journal_entry_id,
},
}
}
const amount =
params.amount ?? Number(payoutRequest.decided_total ?? payoutRequest.requested_total)
// A partial settlement must follow a recorded beslut: without this guard a
// settle with amount < requested_total on an undecided request would flip
// it to partially_paid while bypassing the PATCH lifecycle rule that
// partially_paid requires decided_total: the beslut would never be
// recorded and later PATCH calls would be blocked by ALLOWED_TRANSITIONS.
if (amount < Number(payoutRequest.requested_total) && payoutRequest.decided_total == null) {
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_SETTLE_INVALID_STATE',
details: {
status: payoutRequest.status,
reason:
'Delutbetalning kräver att Skatteverkets beslut registreras först (decided_total via PATCH).',
},
}
}
// Never book more than Skatteverket can owe on this begäran: a larger bank
// row (a moms/skattekonto refund, two begäran in one transfer) would drive
// 1513 into a credit balance and rewrite decided_total to the bank amount.
// The user books such a row another way; this path stays exact.
const expectedAmount = roundOre(Number(payoutRequest.decided_total ?? payoutRequest.requested_total))
if (amount > expectedAmount + 0.005) {
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS',
details: { amount, expected_amount: expectedAmount, status: payoutRequest.status },
}
}
// The voucher is the accounting record: engine failure must block.
let journalEntryId: string
try {
const entry = await createRotRutPayoutEntry(supabase, companyId, userId, {
requestId: payoutRequest.id,
requestName: payoutRequest.name,
deductionType: payoutRequest.deduction_type,
paymentDate: params.paymentDate,
amount,
bankAccount: params.bankAccount,
})
journalEntryId = entry.id
} catch (engineError) {
return { ok: false, kind: 'error', error: engineError, stage: 'book' }
}
const fullyPaid = amount >= Number(payoutRequest.requested_total)
const update: Record<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()
if (updateError) {
// The voucher exists (immutable per BFL) but the request row didn't
// absorb the link: surface loudly, do NOT try to unbook.
log.error('rot/rut payout entry booked but request update failed', updateError, {
journalEntryId,
payoutRequestId: params.requestId,
})
return { ok: false, kind: 'error', error: updateError, stage: 'update' }
}
if (!updated) {
log.error('rot/rut payout entry booked but request was settled concurrently', undefined, {
journalEntryId,
payoutRequestId: params.requestId,
})
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_SETTLE_RACE',
details: { journal_entry_id: journalEntryId, request_id: params.requestId },
}
}
if (params.transactionId) {
// Optimistic lock on the pointer the route read: null for a free row, or
// the stale id of a reversed entry (issue #988) that the route judged not
// live. A concurrent booking between that read and this write changes
// the pointer, so the write matches 0 rows instead of silently
// overwriting it (same CAS contract as link-journal-entry.ts).
const previousJournalEntryId = params.previousJournalEntryId ?? null
const txUpdate = supabase
.from('transactions')
.update({
journal_entry_id: journalEntryId,
is_business: true,
category: 'income_other',
potential_invoice_id: null,
potential_supplier_invoice_id: null,
potential_rot_rut_payout_request_id: null,
// The match supersedes any prior reconciliation link (mirrors
// match-invoice): a literal null keeps the phantom-column scanner
// able to verify the column set.
reconciliation_method: null,
})
.eq('id', params.transactionId)
.eq('company_id', companyId)
const { data: linkedRows, error: linkError } = await (previousJournalEntryId === null
? txUpdate.is('journal_entry_id', null)
: txUpdate.eq('journal_entry_id', previousJournalEntryId)
).select('id')
if (linkError || !linkedRows || linkedRows.length === 0) {
// Voucher booked and request settled, but the bank row is not linked:
// the user can still attach it via "Matcha mot befintlig verifikation".
// Say exactly that instead of pretending the match went through.
log.error('rot/rut payout settled but transaction link failed', linkError ?? undefined, {
journalEntryId,
payoutRequestId: params.requestId,
transactionId: params.transactionId,
reason: linkError?.message ?? 'optimistic lock returned 0 rows',
})
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_MATCH_TX_LINK_FAILED',
details: { journal_entry_id: journalEntryId, request_id: params.requestId },
}
}
// An utbetalningsbesked pinned on the bank row becomes the voucher's
// underlag (BFL 5 kap 6 §), as every other booking path does.
await propagateUnderlagForBookedTransaction(
supabase,
companyId,
params.transactionId,
journalEntryId,
)
await logMatchEvent(supabase, userId, params.transactionId, 'matched', {
matchConfidence: 1.0,
matchMethod: 'rot_rut_payout_manual_confirm',
newState: {
journal_entry_id: journalEntryId,
rot_rut_payout_request_id: params.requestId,
request_status: update.status,
amount,
},
})
}
if (fullyPaid) {
const { data: items, error: itemsFetchError } = await supabase
.from('rot_rut_payout_request_items')
.select('id, requested_amount')
.eq('request_id', params.requestId)
if (itemsFetchError) {
log.warn('failed to fetch items for decided_amount mirror', {
payoutRequestId: params.requestId,
message: itemsFetchError.message,
})
}
for (const item of items ?? []) {
const { error: mirrorError } = await supabase
.from('rot_rut_payout_request_items')
.update({ decided_amount: item.requested_amount })
.eq('id', item.id)
if (mirrorError) {
log.warn('failed to mirror decided_amount onto item', {
itemId: item.id,
message: mirrorError.message,
})
}
}
}
// The request is settled: every OTHER bank row still hinting at it is a dead
// suggestion. This row's own hint was cleared by the link update above.
await clearSettledInvoiceSuggestions(
supabase,
companyId,
'rot_rut_payout_request',
params.requestId,
{ exceptTransactionId: params.transactionId ?? null },
)
log.info('rot/rut payout settled', {
userId,
payoutRequestId: params.requestId,
journalEntryId,
amount,
fullyPaid,
transactionId: params.transactionId ?? null,
})
return {
ok: true,
request: updated as SettledRotRutPayoutRequest,
journalEntryId,
amount,
fullyPaid,
}
}