* feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle Follow-up to #2239/#2360 for firms whose every invoice carries ROT/RUT. - /invoices/rot-rut: tiles (at Skatteverket on 1513, awaiting beslut, refused to book, ready to request) and one row per begaran with mark uploaded, cancel, download and "Bokfor nekat belopp"; the Fakturor button links here, ?rot-rut=1 still opens the file dialog. - Beslutsfil import from the UI through the existing import route. - Reclaim of the share Skatteverket refused: one voucher debit 1510 / credit 1513 per invoice (source_type rot_rut_reclaim), CAS-attached to the begaran and guarded by a partial unique index; the invoice reopens for the refused share via invoices.deduction_reclaimed_total, with the customer-share formula and its SQL twin gaining the same term. The payment dialog and bank match then settle the reopened remaining as a plain 1510 clearing; a booked kontantmetod invoice is proposed accrual- shaped so revenue is never recognised twice. Unknown per-invoice split of a partial beslut is refused, never allocated. - MCP: gnubok_list_rot_rut_payout_requests (search-only read) and gnubok_settle_rot_rut_payout (staged write, op settle_rot_rut_payout) sharing one pre-flight + settle with the dashboard match route. - Migrations 20260907140000 (reclaim state, source_type, INSERT guard), 20260907140100/140101 (pending_operations op type). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * chore(rot-rut): renumber migrations after merging main Main already carries 20260907143000 and 20260907150000, so the three rot-rut migrations move to 20260907160000/160100/160101 to keep the applied order monotonic (see memory: migration-version-collisions). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): close the reclaim gaps found by skeptics, CI and review Skeptic refutations (#2397): - payment-sync recomputes remaining with deduction_reclaimed_total, so a storno of a payment on a reopened invoice no longer strands the refused share (R1). - Reclaim refused while an invoice sits in a later live begäran (ROT_RUT_RECLAIM_INVOICE_REREQUESTED); the overview and the MCP list hide the action for the same case (C2). - A reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED) until the reclaim voucher is reversed (R2/C3). - Storno of the reclaim voucher syncs the invoices and the begäran back (rot-rut-reclaim-reversal.ts, hooked into reverseEntry) (R3). - A paid invoice with NULL paid_amount counts its customer share as paid (C4). Crediting an invoice with a reclaimed share is refused on the dashboard, v1 and MCP paths (R4). CI and review: - Build: custom-coded MCP errors via Object.assign, not codedError. - pg-real: column default for default_voucher_series_per_source_type re-stated with rot_rut_reclaim (20260907160200); the default test now re-applies the latest default migration. - Checks: accounted-api skill regenerated (journal-entries source types). - CodeRabbit/Superagent: per-item refused shares must reconcile with the request-level beslut; per-invoice reopen through the idempotent RPC apply_rot_rut_reclaim_invoice (20260907160300) with a resume path; update-stage settle failures keep the voucher id (failed_partial); Stockholm calendar date for the booking; existing-voucher tab uses the same proposal method; MCP stage checks bank_line junction rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): carry the voucher id through the match outcome type; date the reclaim on the beslut - The shared match outcome now declares journalEntryId on update-stage errors, matching the settle service (Core Build TS2339 on 2d6cece1a). - The reclaim voucher is dated on the Swedish calendar day of Skatteverkets beslut (decided_at), today only when no decision date is recorded, and the confirm dialog states the date (Swedish accounting review: BFL 5 kap 6-7 §, datum for affarshandelsen). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): reclaim RPCs validate the share and derive the invoice state; idempotent revert; v1 credit guard reads the column - apply_rot_rut_reclaim_invoice (20260907160400 replaces the 160300 signature) takes only the refused share, validates it against the locked item, request and invoice, and derives remaining_amount and status from the INSERT-guard formula (review: caller-supplied accounting values, CWE-862). revert_rot_rut_reclaim_invoice mirrors it for a reversed reclaim voucher; the request link is cleared only after every leg. - v1 credit route projection includes deduction_reclaimed_total so the reclaim guard actually fires there. - Overview keeps "Bokfor nekat belopp" available while legs are pending (resume after a partial failure). - Match and settle routes attach journal_entry_id on update-stage errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
627 lines
22 KiB
TypeScript
627 lines
22 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)
|
|
*
|
|
* 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,
|
|
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'
|
|
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_SET_AMOUNT'
|
|
| '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(). At
|
|
* stage 'update' the voucher is already posted: journalEntryId names it so
|
|
* a caller can persist the partial state instead of losing the id.
|
|
*/
|
|
| { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update'; journalEntryId?: string }
|
|
|
|
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,
|
|
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' }
|
|
}
|
|
|
|
if (!isSettleable(payoutRequest)) {
|
|
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.
|
|
// 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 {
|
|
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 { 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 as Error, {
|
|
journalEntryId,
|
|
payoutRequestId: params.requestId,
|
|
})
|
|
return { ok: false, kind: 'error', error: updateError, stage: 'update', journalEntryId }
|
|
}
|
|
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,
|
|
})
|
|
return {
|
|
ok: false,
|
|
kind: 'code',
|
|
code: 'ROT_RUT_SETTLE_RACE',
|
|
details: { journal_entry_id: journalEntryId, request_id: params.requestId },
|
|
}
|
|
}
|
|
|
|
if (params.transactionId) {
|
|
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',
|
|
code: 'ROT_RUT_MATCH_TX_LINK_FAILED',
|
|
details: { journal_entry_id: journalEntryId, request_id: params.requestId },
|
|
}
|
|
}
|
|
}
|
|
|
|
if (fullyPaid) {
|
|
await mirrorDecidedAmounts(supabase, params.requestId)
|
|
}
|
|
|
|
// 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,
|
|
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'; journalEntryId?: string }
|
|
|
|
/**
|
|
* 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', journalEntryId }
|
|
}
|
|
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 }
|
|
}
|