feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle (#2397)

* 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>
This commit is contained in:
Mattsson
2026-09-08 00:13:05 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 5d735097a1
commit fdcb7d937e
52 changed files with 4016 additions and 122 deletions
+10
View File
@@ -294,6 +294,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
'webshop_order',
'expense_claim',
'expense_payout',
'rot_rut_reclaim',
])
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
@@ -712,6 +713,15 @@ export const RotRutSettleSchema = z.object({
.optional(),
})
/**
* POST /api/rot-rut/payout-requests/[id]/reclaim: book the share Skatteverket
* refused back onto the customer(s). The amounts come from the recorded
* beslut, never from the body; only the booking date is the caller's.
*/
export const RotRutReclaimSchema = z.object({
booking_date: isoDate,
})
// The beslutsfil JSON downloaded from Skatteverkets rot/rut e-tjänst
// (dev_docs/skatteverket/husavdrag/exempel_beslut.json + ht.raml).
export const RotRutBeslutFileSchema = z.object({
+4
View File
@@ -413,6 +413,10 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_undo_sie_import: 'bookkeeping:write',
gnubok_post_annual_depreciation: 'bookkeeping:write',
gnubok_import_rot_rut_beslut: 'invoices:write',
// Rot/rut begäran history (status, beslut, settlement, refused share)
gnubok_list_rot_rut_payout_requests: 'invoices:read',
// Skatteverkets utbetalning: bank row booked against its begäran (stages)
gnubok_settle_rot_rut_payout: 'transactions:write',
gnubok_list_verifikat_without_documents: 'transactions:read',
gnubok_find_voucher_candidates_for_invoice: 'invoices:read',
gnubok_propose_dispositioner: 'reports:read',
@@ -482,3 +482,40 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
expect(tablesUpdated('transactions').length).toBe(2)
})
})
describe('syncInvoiceStatusFromPaymentEntry: reclaimed ROT/RUT share (rot_rut_reclaim)', () => {
// 25 000 invoice, 7 500 deduction, Skatteverket refused 2 500 and the
// reclaim moved it onto the customer; customer paid 17 500 + 2 500. The
// storno of the 2 500 payment must leave 2 500 open, not 0 (#2397 R1).
it('keeps the refused share in the remaining after a payment storno', async () => {
const { supabase, updatePayload } = createRecordingSupabase([
{ data: { amount: 2500 } }, // invoice_payments select amount
{
data: {
paid_amount: 20000,
total: 25000,
deduction_total: 7500,
deduction_reclaimed_total: 2500,
due_date: '2099-12-31',
},
}, // invoices select
{ data: null }, // invoices update
{ data: [] }, // invoice_payments select transaction_id
{ data: null }, // invoice_payments delete
{ data: null }, // transactions update
])
await syncInvoiceStatusFromPaymentEntry(
supabase,
'co-1',
{ id: 'entry-1', source_type: 'invoice_paid', source_id: 'invoice-1' } as JournalEntry,
)
expect(updatePayload('invoices')).toEqual({
status: 'partially_paid',
paid_at: null,
paid_amount: 17500,
remaining_amount: 2500,
})
})
})
@@ -710,3 +710,40 @@ describe('proposePaymentLines: ROT/RUT-avdrag (fakturamodellen)', () => {
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '12500' })
})
})
describe('proposePaymentLines: ROT/RUT invoice reopened by a reclaim (rot_rut_reclaim)', () => {
it('proposes the reopened remaining as a plain 1510 clearing', () => {
// 25 000 invoice, 7 500 deduction, customer paid 17 500, Skatteverket
// refused 2 500 and the reclaim voucher moved it onto 1510.
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput({ total: 25000 }),
deduction_total: 7500,
deduction_reclaimed_total: 2500,
paid_amount: 17500,
remaining_amount: 2500,
},
accountingMethod: 'accrual',
entityType: 'enskild_firma',
})
expect(lines).toHaveLength(2)
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '2500' })
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '2500' })
})
it('leaves a ROT/RUT invoice without a reclaim on the legacy customer-share proposal', () => {
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput({ total: 25000 }),
deduction_total: 7500,
paid_amount: 0,
remaining_amount: 17500,
},
accountingMethod: 'accrual',
entityType: 'enskild_firma',
})
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '17500' })
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '17500' })
})
})
+9
View File
@@ -34,6 +34,7 @@ import {
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
import { syncRotRutReclaimAfterReversal } from '@/lib/invoices/rot-rut-reclaim-reversal'
import { getActor } from '@/lib/bookkeeping/actor-context'
import type {
AssetDisposalType,
@@ -1455,6 +1456,14 @@ export async function reverseEntry(
await syncInvoiceStatusFromPaymentEntry(supabase, companyId, original as JournalEntry)
}
// A reversed rot_rut_reclaim voucher hands the refused share back to
// Skatteverket's side: the invoices it reopened close again, the begäran
// becomes reclaimable again (same business-state mirror as the payment
// sync above, skeptic #2397 R3).
if (original.source_type === 'rot_rut_reclaim') {
await syncRotRutReclaimAfterReversal(supabase, companyId, original.id)
}
// Fetch complete reversal entry with lines
const { data: completeEntry } = await supabase
.from('journal_entries')
+6 -1
View File
@@ -533,6 +533,7 @@ function invoiceOutstandingAmount(invoice: Invoice): number {
remaining_amount?: number | null
paid_amount?: number | null
deduction_total?: number | null
deduction_reclaimed_total?: number | null
}
// A payment is being booked, so a stored 0 cannot mean "settled": rows
// written by paths that bypass buildInvoiceWriteData (imports, sandbox seed,
@@ -544,7 +545,11 @@ function invoiceOutstandingAmount(invoice: Invoice): number {
}
const paid = typeof inv.paid_amount === 'number' ? inv.paid_amount : 0
const deduction = typeof inv.deduction_total === 'number' ? inv.deduction_total : 0
return roundOre(invoice.total - paid - deduction)
// A refused deduction (rot_rut_reclaim) is the customer's again: same
// formula as invoiceCustomerShare and the SQL INSERT guard.
const reclaimed =
typeof inv.deduction_reclaimed_total === 'number' ? inv.deduction_reclaimed_total : 0
return roundOre(invoice.total - paid - deduction + reclaimed)
}
/**
+12 -4
View File
@@ -153,7 +153,7 @@ export async function syncInvoiceStatusFromPaymentEntry(
const { data: customerInvoice } = await supabase
.from('invoices')
.select('paid_amount, total, due_date, deduction_total')
.select('paid_amount, total, due_date, deduction_total, deduction_reclaimed_total')
.eq('id', entry.source_id)
.eq('company_id', companyId)
.single()
@@ -185,12 +185,20 @@ export async function syncInvoiceStatusFromPaymentEntry(
// one shared definition (lib/invoices/customer-share.ts); the
// Math.max(0, ...) mirrors the guard's GREATEST(0, ...) because this
// value is persisted into the column.
const deductionTotal =
(customerInvoice as { deduction_total?: number | null }).deduction_total ?? 0
// A refused deduction that a rot_rut_reclaim voucher moved back onto
// the customer is the customer's again: without this term a storno of
// any payment on a reopened invoice wrote a remaining short by the
// reclaimed share and no path could settle it (skeptic #2397 R1).
const { deduction_total: deductionTotal = 0, deduction_reclaimed_total: reclaimedTotal = 0 } =
customerInvoice as { deduction_total?: number | null; deduction_reclaimed_total?: number | null }
const newRemaining = Math.max(
0,
invoiceCustomerOutstanding(
{ total: customerInvoice.total, deduction_total: deductionTotal },
{
total: customerInvoice.total,
deduction_total: deductionTotal ?? 0,
deduction_reclaimed_total: reclaimedTotal ?? 0,
},
safePaidAmount,
),
)
+18 -4
View File
@@ -39,6 +39,13 @@ export interface ProposePaymentLinesInput {
* doing so is what made every ROT/RUT invoice fail the overpayment guard.
*/
deduction_total?: number | null
/**
* The part of the deduction Skatteverket refused and that a
* rot_rut_reclaim voucher moved back onto the customer (debit 1510).
* When set, the invoice's remaining is a plain 1510 fordran again and the
* remaining-aware proposal applies to it.
*/
deduction_reclaimed_total?: number | null
/**
* Dimensions PR7: the invoice's default bag. Stamped on every proposed
* line: the payment dialog always submits its (editable) lines, so the
@@ -145,7 +152,10 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
* pure function does not carry; the dialog's FX path already handles it.
* - On a ROT/RUT invoice the outstanding remainder is (or includes)
* Skatteverket's share, which sits on 1513 and is settled by the ROT/RUT
* payout flow, not by clearing 1510 here.
* payout flow, not by clearing 1510 here. The one exception is a
* deduction Skatteverket refused: the reclaim voucher has already moved
* that share onto 1510 (deduction_reclaimed_total > 0), so the reopened
* remaining is an ordinary customer fordran and is proposed as such.
*
* Two shapes:
* - 0 < remaining < 1 kr (the stuck öresavrundning case): a bank-less
@@ -163,11 +173,15 @@ function proposeRemainingAwareLines(
): FormLine[] | null {
if (accountingMethod !== 'accrual') return null
if (invoice.currency !== 'SEK') return null
if ((invoice.deduction_total ?? 0) > 0) return null
const reclaimed = invoice.deduction_reclaimed_total ?? 0
if ((invoice.deduction_total ?? 0) > 0 && !(reclaimed > 0)) return null
const total = roundOre(invoice.total)
// With a reclaimed deduction the customer's whole fordran is total minus
// the deduction plus the refused share; "partial" is measured against
// that, not the printed total.
const total = roundOre(invoice.total - (invoice.deduction_total ?? 0) + reclaimed)
const remaining = roundOre(
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount ?? 0),
invoice.remaining_amount ?? total - (invoice.paid_amount ?? 0),
)
const hasPartial = remaining > ORE_TOLERANCE && total - remaining > ORE_TOLERANCE
if (!hasPartial) return null
+75
View File
@@ -132,3 +132,78 @@ export async function createRotRutPayoutEntry(
],
})
}
/**
* Reclaim voucher for the share of a begäran Skatteverket refused.
*
* The 1513 fordran booked at issue was never paid by Skatteverket, so it is
* the buyer's again (HUSFL 2009:194; swedish-invoice-compliance section 8:
* "SKV denies: Debit 1510, Credit 1513"):
*
* Debit 1510 Kundfordringar [refused share, per invoice]
* Credit 1513 Skattereduktion rot/rut [refused share, per invoice]
*
* One voucher per begäran (source_id = request id, guarded by the partial
* unique index journal_entries_rot_rut_reclaim_live_unique), one 1510 and one
* 1513 leg per invoice so the kundreskontra can be read per faktura.
*/
export interface RotRutReclaimLeg {
invoiceId: string
invoiceNumber: string | null
/** The refused share for this invoice (kr). */
amount: number
}
export async function createRotRutReclaimEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
params: {
requestId: string
requestName: string
deductionType: 'rot' | 'rut'
bookingDate: string
/** One leg per invoice with a refused share; at least one, all > 0. */
legs: RotRutReclaimLeg[]
},
): Promise<JournalEntry> {
const legs = params.legs
.map((leg) => ({ ...leg, amount: roundOre(leg.amount) }))
.filter((leg) => leg.amount > 0)
if (legs.length === 0) {
throw new Error('A rot/rut reclaim voucher needs at least one refused share')
}
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, params.bookingDate)
if (!fiscalPeriodId) {
throw new Error(`No open fiscal period found for booking date ${params.bookingDate}`)
}
const label = params.deductionType === 'rut' ? 'RUT' : 'ROT'
const description = `Nekat ${label}-avdrag från Skatteverket (${params.requestName})`
const legDescription = (leg: RotRutReclaimLeg) =>
`Nekat ${label}-avdrag faktura ${leg.invoiceNumber ?? leg.invoiceId}`
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: params.bookingDate,
description,
source_type: 'rot_rut_reclaim',
source_id: params.requestId,
lines: [
...legs.map((leg) => ({
account_number: '1510',
debit_amount: leg.amount,
credit_amount: 0,
line_description: legDescription(leg),
})),
...legs.map((leg) => ({
account_number: '1513',
debit_amount: 0,
credit_amount: leg.amount,
line_description: legDescription(leg),
})),
],
}
return createJournalEntry(supabase, companyId, userId, input)
}
@@ -153,6 +153,7 @@ export const STANDARD_VOUCHER_SERIES_MAP: Readonly<Record<JournalEntrySourceType
invoice_paid: 'C',
invoice_cash_payment: 'C',
rot_rut_payout: 'C',
rot_rut_reclaim: 'C',
supplier_invoice_registered: 'D',
supplier_credit_note: 'D',
supplier_invoice_privately_paid: 'D',
+63
View File
@@ -1033,6 +1033,62 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Beloppet stämmer inte med de valda utläggen. Välj de utlägg som överföringen täcker.',
message_en: 'The amount does not match the selected expense claims. Pick the claims this transfer covers.',
},
// Reclaim: Skatteverkets avslag booked back onto the customer
ROT_RUT_RECLAIM_NO_BESLUT: {
httpStatus: 400,
message_sv:
'Skatteverkets beslut är inte registrerat för begäran. Importera beslutsfilen eller registrera beslutet först.',
message_en:
"Skatteverket's decision is not recorded for this request. Import the decision file or record the decision first.",
},
ROT_RUT_RECLAIM_NOTHING_REFUSED: {
httpStatus: 400,
message_sv: 'Skatteverket beviljade hela begäran: det finns inget nekat belopp att bokföra.',
message_en: 'Skatteverket approved the whole request: there is no refused amount to book.',
},
ROT_RUT_RECLAIM_ALREADY_DONE: {
httpStatus: 409,
message_sv: 'Det nekade beloppet är redan bokfört för den här begäran.',
message_en: 'The refused amount has already been booked for this request.',
},
ROT_RUT_RECLAIM_SPLIT_UNKNOWN: {
httpStatus: 400,
message_sv:
'Beslutet är registrerat som en totalsumma för flera fakturor. Importera Skatteverkets beslutsfil så att det nekade beloppet kan fördelas per faktura.',
message_en:
"The decision was recorded as one total for several invoices. Import Skatteverket's decision file so the refused amount can be split per invoice.",
},
ROT_RUT_RECLAIM_INVOICE_NOT_BOOKED: {
httpStatus: 400,
message_sv:
'Fakturan har ingen verifikation, så det finns ingen fordran på konto 1513 att flytta. Bokför fakturan först.',
message_en:
'The invoice has no voucher, so there is no receivable on account 1513 to move. Book the invoice first.',
},
ROT_RUT_RECLAIM_INVOICE_NOT_OPEN: {
httpStatus: 400,
message_sv: 'Fakturan är makulerad eller krediterad och kan inte öppnas igen för det nekade beloppet.',
message_en: 'The invoice is cancelled or credited and cannot be reopened for the refused amount.',
},
ROT_RUT_RECLAIM_CURRENCY: {
httpStatus: 400,
message_sv: 'Det nekade beloppet kan bara bokföras för fakturor i SEK.',
message_en: 'The refused amount can only be booked for invoices in SEK.',
},
ROT_RUT_RECLAIM_INVOICE_REREQUESTED: {
httpStatus: 409,
message_sv:
'Minst en faktura i begäran ingår i en senare begäran som inte är avslagen. Det nekade beloppet kan inte bokföras på kunden när Skatteverket prövar fakturan igen.',
message_en:
'At least one invoice in this request is part of a later request that is not rejected. The refused amount cannot be booked onto the customer while Skatteverket is reviewing the invoice again.',
},
ROT_RUT_RECLAIM_RACE: {
httpStatus: 409,
message_sv:
'Det nekade beloppet hann redan bokföras av en annan åtgärd. Verifikationen som skapades kan inte kopplas: kontrollera bokföringen på konto 1513 och 1510.',
message_en:
'The refused amount was already booked by another action. The voucher that was created could not be attached: check the bookkeeping on accounts 1513 and 1510.',
},
ROT_RUT_FILE_CREATE_FAILED: {
httpStatus: 500,
message_sv: 'Filen kunde inte skapas.',
@@ -1075,6 +1131,13 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Fakturan har redan krediterats.',
message_en: 'Invoice has already been credited.',
},
INVOICE_CREDIT_ROT_RUT_RECLAIMED: {
httpStatus: 400,
message_sv:
'Fakturan har ett nekat ROT/RUT-avdrag bokfört som kundfordran. Makulera den bokningen (verifikationen med nekat avdrag) innan fakturan krediteras, annars stämmer inte kreditfakturans fördelning mellan konto 1510 och 1513.',
message_en:
'The invoice carries a refused ROT/RUT deduction booked as a customer receivable. Reverse that voucher before crediting the invoice, otherwise the credit note splits 1510 and 1513 wrongly.',
},
INVOICE_CREDIT_NOT_SENT: {
httpStatus: 400,
message_sv: 'Endast skickade, betalda eller förfallna fakturor kan krediteras.',
@@ -85,3 +85,26 @@ describe('invoiceCustomerOutstanding', () => {
}
})
})
describe('invoiceCustomerShare with a reclaimed deduction (rot_rut_reclaim)', () => {
it('adds the refused share back onto the customer', () => {
// Skatteverket refused 2 500 of the 7 500: the customer owes 17 500 + 2 500.
expect(invoiceCustomerShare({ ...rot, deduction_reclaimed_total: 2500 })).toBe(20000)
// Full avslag: the whole invoice is the customer's again.
expect(invoiceCustomerShare({ ...rot, deduction_reclaimed_total: 7500 })).toBe(25000)
})
it('treats null, undefined and zero reclaimed as nothing reclaimed', () => {
expect(invoiceCustomerShare({ ...rot, deduction_reclaimed_total: null })).toBe(17500)
expect(invoiceCustomerShare({ ...rot, deduction_reclaimed_total: undefined })).toBe(17500)
expect(invoiceCustomerShare({ ...rot, deduction_reclaimed_total: 0 })).toBe(17500)
})
it('never reclaims more than the deduction (CHECK twin)', () => {
expect(invoiceCustomerShare({ ...rot, deduction_reclaimed_total: 9000 })).toBe(25000)
})
it('feeds the outstanding: customer paid their share, refused share is open', () => {
expect(invoiceCustomerOutstanding({ ...rot, deduction_reclaimed_total: 2500 }, 17500)).toBe(2500)
})
})
@@ -53,7 +53,7 @@ describe('fetchInvoiceRegisterCoverage', () => {
expect(findCall('journal_entries', 'not')).toEqual([
'source_type',
'in',
'("invoice_created","invoice_paid","invoice_cash_payment","credit_note","reminder_fee","rot_rut_payout","storno","correction")',
'("invoice_created","invoice_paid","invoice_cash_payment","credit_note","reminder_fee","rot_rut_payout","rot_rut_reclaim","storno","correction")',
])
// AR-scoped and DEBIT-only: an advance payment crediting 1510 before the
// first invoice is not evidence of register-external invoices.
@@ -875,3 +875,16 @@ describe('deadline + helpers', () => {
expect(xml).toMatch(/<ns2:Fastighetsbeteckning>TEST 1:7<\/ns2:Fastighetsbeteckning>\s*<ns2:UtfortArbete>\s*<ns2:GlasPlatarbete>/)
})
})
describe('evaluateInvoiceForFile: reclaimed deduction (rot_rut_reclaim)', () => {
it('DEDUCTION_RECLAIMED blocks an invoice whose refused share was booked onto the customer', () => {
const result = evaluateInvoiceForFile('rot', makeRotInvoice({ deduction_reclaimed_total: 1000 }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('DEDUCTION_RECLAIMED')
})
it('a zero or absent reclaimed total changes nothing', () => {
expect(evaluateInvoiceForFile('rot', makeRotInvoice({ deduction_reclaimed_total: 0 })).ok).toBe(true)
expect(evaluateInvoiceForFile('rot', makeRotInvoice()).ok).toBe(true)
})
})
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { createLogger } from '@/lib/logger'
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()
vi.mock('@/lib/bookkeeping/settlement-account', () => ({
resolveSettlementAccount: (...args: unknown[]) => mockResolveSettlementAccount(...args),
}))
const mockHasLiveLink = vi.fn()
vi.mock('@/lib/transactions/link-journal-entry', () => ({
hasLiveJournalEntryLink: (...args: unknown[]) => mockHasLiveLink(...args),
}))
import { matchTransactionToRotRutPayout } from '../rot-rut-match-transaction'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const supabase = mockSupabase as unknown as SupabaseClient
const log = createLogger('test')
const TX_ID = '11111111-1111-4111-8111-111111111111'
const REQUEST_A = '22222222-2222-4222-8222-222222222222'
const REQUEST_B = '44444444-4444-4444-8444-444444444444'
function makeTxRow(overrides: Record<string, unknown> = {}) {
return {
id: TX_ID,
date: '2026-07-10',
amount: 3000,
currency: 'SEK',
journal_entry_id: null,
cash_account_id: 'ca-1',
transaction_voucher_links: [],
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
reset()
mockResolveSettlementAccount.mockResolvedValue('1930')
mockHasLiveLink.mockResolvedValue(false)
mockSettle.mockResolvedValue({
ok: true,
journalEntryId: 'je-1',
amount: 3000,
fullyPaid: true,
request: { id: REQUEST_A, name: 'ROT 2026-07', status: 'paid' },
})
mockSettleSet.mockResolvedValue({
ok: true,
journalEntryId: 'je-2',
amount: 5000,
requests: [
{ id: REQUEST_A, name: 'ROT 2026-07', status: 'paid' },
{ id: REQUEST_B, name: 'RUT 2026-07', status: 'paid' },
],
})
})
describe('matchTransactionToRotRutPayout', () => {
it('refuses an empty request list before touching the database', async () => {
const outcome = await matchTransactionToRotRutPayout(
supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [] }, log,
)
expect(outcome).toEqual({ ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' })
})
it('reports an unknown transaction', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const outcome = await matchTransactionToRotRutPayout(
supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [REQUEST_A] }, log,
)
expect(outcome).toMatchObject({ ok: false, code: 'TX_CATEGORIZE_TX_NOT_FOUND' })
})
it('refuses an expense row and a non-SEK row', async () => {
enqueue({ data: makeTxRow({ amount: -3000 }) })
const expense = await matchTransactionToRotRutPayout(
supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [REQUEST_A] }, log,
)
expect(expense).toMatchObject({ ok: false, code: 'ROT_RUT_MATCH_NOT_INCOME' })
reset()
enqueue({ data: makeTxRow({ currency: 'EUR' }) })
const foreign = await matchTransactionToRotRutPayout(
supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [REQUEST_A] }, log,
)
expect(foreign).toMatchObject({ ok: false, code: 'ROT_RUT_MATCH_CURRENCY' })
expect(mockSettle).not.toHaveBeenCalled()
})
it('refuses a row that is already booked (live pointer or bank_line junction)', async () => {
enqueue({ data: makeTxRow({ journal_entry_id: 'je-old' }) })
mockHasLiveLink.mockResolvedValue(true)
const live = await matchTransactionToRotRutPayout(
supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [REQUEST_A] }, log,
)
expect(live).toMatchObject({ ok: false, code: 'ROT_RUT_MATCH_TX_ALREADY_LINKED' })
reset()
mockHasLiveLink.mockResolvedValue(false)
enqueue({
data: makeTxRow({ transaction_voucher_links: [{ journal_entry_id: 'je-bulk', role: 'bank_line' }] }),
})
const junction = await matchTransactionToRotRutPayout(
supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [REQUEST_A] }, log,
)
expect(junction).toMatchObject({ ok: false, code: 'ROT_RUT_MATCH_TX_ALREADY_LINKED' })
expect(mockSettle).not.toHaveBeenCalled()
})
it('settles one begäran through the single writer with the row as amount, date and account', async () => {
enqueue({ data: makeTxRow({ journal_entry_id: 'je-stale' }) })
mockResolveSettlementAccount.mockResolvedValue('1920')
const outcome = await matchTransactionToRotRutPayout(
supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [REQUEST_A] }, log,
)
expect(outcome).toMatchObject({
ok: true,
journalEntryId: 'je-1',
amount: 3000,
request: { id: REQUEST_A },
requests: [{ id: REQUEST_A }],
})
expect(mockSettle).toHaveBeenCalledWith(supabase, 'user-1', 'company-1', {
requestId: REQUEST_A,
paymentDate: '2026-07-10',
amount: 3000,
bankAccount: '1920',
transactionId: TX_ID,
previousJournalEntryId: 'je-stale',
})
expect(mockSettleSet).not.toHaveBeenCalled()
})
it('settles a bundle through the set writer, de-duplicating ids', async () => {
enqueue({ data: makeTxRow({ amount: 5000 }) })
const outcome = await matchTransactionToRotRutPayout(
supabase, 'user-1', 'company-1',
{ transactionId: TX_ID, requestIds: [REQUEST_A, REQUEST_B, REQUEST_A] },
log,
)
expect(outcome).toMatchObject({ ok: true, journalEntryId: 'je-2', amount: 5000, fullyPaid: true })
expect((outcome as { request?: unknown }).request).toBeUndefined()
expect(mockSettleSet).toHaveBeenCalledWith(supabase, 'user-1', 'company-1', {
requestIds: [REQUEST_A, REQUEST_B],
paymentDate: '2026-07-10',
amount: 5000,
bankAccount: '1930',
transactionId: TX_ID,
previousJournalEntryId: null,
})
expect(mockSettle).not.toHaveBeenCalled()
})
it('passes a writer refusal through unchanged', async () => {
enqueue({ data: makeTxRow() })
mockSettle.mockResolvedValue({
ok: false,
kind: 'code',
code: 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS',
details: { amount: 3000, expected_amount: 2500 },
})
const outcome = await matchTransactionToRotRutPayout(
supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [REQUEST_A] }, log,
)
expect(outcome).toMatchObject({ ok: false, code: 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS' })
})
})
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { syncRotRutReclaimAfterReversal } from '../rot-rut-reclaim-reversal'
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const supabase = mockSupabase as unknown as SupabaseClient
const REQUEST_ID = '22222222-2222-4222-8222-222222222222'
const INVOICE_A = '11111111-1111-4111-8111-111111111111'
const INVOICE_B = '33333333-3333-4333-8333-333333333333'
beforeEach(() => {
vi.clearAllMocks()
reset()
})
describe('syncRotRutReclaimAfterReversal', () => {
it('does nothing when the reversed voucher is not a reclaim of any begäran', async () => {
enqueue({ data: null })
await syncRotRutReclaimAfterReversal(supabase, 'company-1', 'je-x')
expect(mockSupabase.rpc).not.toHaveBeenCalled()
expect(findCalls('rot_rut_payout_requests', 'update')).toHaveLength(0)
})
it('reverts every marked leg through the RPC, then frees the begäran', async () => {
enqueue({ data: { id: REQUEST_ID } })
enqueue({
data: [
{ id: 'i1', invoice_id: INVOICE_A, reclaimed_amount: 2500 },
{ id: 'i2', invoice_id: INVOICE_B, reclaimed_amount: null }, // never applied: skipped
],
})
enqueue({ data: { reverted: true, remaining_amount: 0, status: 'paid' } }) // revert i1
enqueue({ data: null }) // request reset
await syncRotRutReclaimAfterReversal(supabase, 'company-1', 'je-reclaim')
expect(mockSupabase.rpc).toHaveBeenCalledTimes(1)
expect(mockSupabase.rpc).toHaveBeenCalledWith('revert_rot_rut_reclaim_invoice', {
p_item_id: 'i1',
p_invoice_id: INVOICE_A,
p_company_id: 'company-1',
})
const requestUpdates = findCalls('rot_rut_payout_requests', 'update')
expect(requestUpdates).toHaveLength(1)
expect(requestUpdates[0][0]).toEqual({ reclaim_journal_entry_id: null, reclaimed_at: null })
})
it('keeps the request link when a leg fails, so a re-run can finish', async () => {
enqueue({ data: { id: REQUEST_ID } })
enqueue({
data: [
{ id: 'i1', invoice_id: INVOICE_A, reclaimed_amount: 2500 },
{ id: 'i2', invoice_id: INVOICE_B, reclaimed_amount: 1000 },
],
})
enqueue({ data: null, error: { message: 'deadlock detected' } }) // revert i1 fails
await syncRotRutReclaimAfterReversal(supabase, 'company-1', 'je-reclaim')
expect(mockSupabase.rpc).toHaveBeenCalledTimes(1)
expect(findCalls('rot_rut_payout_requests', 'update')).toHaveLength(0)
})
})
@@ -0,0 +1,390 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
const mockCreateReclaimEntry = vi.fn()
vi.mock('@/lib/bookkeeping/rot-rut-entries', () => ({
createRotRutReclaimEntry: (...args: unknown[]) => mockCreateReclaimEntry(...args),
}))
import { computeRefusedShares, reclaimRotRutRefusal } from '../rot-rut-reclaim'
const { supabase: mockSupabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
const supabase = mockSupabase as unknown as SupabaseClient
const REQUEST_ID = '22222222-2222-4222-8222-222222222222'
const INVOICE_A = '11111111-1111-4111-8111-111111111111'
const INVOICE_B = '33333333-3333-4333-8333-333333333333'
function makeRequestRow(overrides: Record<string, unknown> = {}) {
return {
id: REQUEST_ID,
name: 'RUT 2026-08',
deduction_type: 'rut',
status: 'partially_paid',
requested_total: 5000,
decided_total: 3000,
decided_at: '2026-08-20T10:00:00Z',
reclaim_journal_entry_id: null,
...overrides,
}
}
function makeInvoiceRow(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
invoice_number: id === INVOICE_A ? '2026-001' : '2026-002',
status: 'paid',
currency: 'SEK',
total: 10000,
paid_amount: 5000,
deduction_total: 5000,
deduction_reclaimed_total: 0,
journal_entry_id: 'je-issue',
document_type: 'invoice',
...overrides,
}
}
function makeItem(
id: string,
invoiceId: string,
requested: number,
decided: number | null,
invoiceOverrides: Record<string, unknown> = {},
) {
return {
id,
invoice_id: invoiceId,
requested_amount: requested,
decided_amount: decided,
reclaimed_amount: null,
invoice: makeInvoiceRow(invoiceId, invoiceOverrides),
}
}
/** Queue order: request, items, sibling-request scan (empty), then the writes. */
function enqueueOpen(request: Record<string, unknown>, items: unknown[], siblings: unknown[] = []) {
enqueue({ data: request })
enqueue({ data: items })
enqueue({ data: siblings })
}
beforeEach(() => {
vi.clearAllMocks()
reset()
mockCreateReclaimEntry.mockResolvedValue({ id: 'je-reclaim' })
})
describe('computeRefusedShares', () => {
it('needs a recorded beslut', () => {
expect(
computeRefusedShares({ requested_total: 5000, decided_total: null, decided_at: null }, [
{ id: 'i1', invoice_id: INVOICE_A, requested_amount: 5000, decided_amount: null },
]),
).toEqual({ ok: false, code: 'ROT_RUT_RECLAIM_NO_BESLUT' })
})
it('refuses every item on a full avslag, even without per-item amounts', () => {
const result = computeRefusedShares(
{ requested_total: 5000, decided_total: 0, decided_at: '2026-08-20' },
[
{ id: 'i1', invoice_id: INVOICE_A, requested_amount: 3000, decided_amount: null },
{ id: 'i2', invoice_id: INVOICE_B, requested_amount: 2000, decided_amount: null },
],
)
expect(result).toEqual({
ok: true,
total: 5000,
shares: [
{ itemId: 'i1', invoiceId: INVOICE_A, refused: 3000 },
{ itemId: 'i2', invoiceId: INVOICE_B, refused: 2000 },
],
})
})
it('uses the request total for a single-item begäran recorded without a per-item amount', () => {
const result = computeRefusedShares(
{ requested_total: 5000, decided_total: 3000, decided_at: '2026-08-20' },
[{ id: 'i1', invoice_id: INVOICE_A, requested_amount: 5000, decided_amount: null }],
)
expect(result).toMatchObject({ ok: true, total: 2000, shares: [{ refused: 2000 }] })
})
it('refuses to guess the split of a multi-item partial beslut without per-item amounts', () => {
const result = computeRefusedShares(
{ requested_total: 5000, decided_total: 3000, decided_at: '2026-08-20' },
[
{ id: 'i1', invoice_id: INVOICE_A, requested_amount: 3000, decided_amount: null },
{ id: 'i2', invoice_id: INVOICE_B, requested_amount: 2000, decided_amount: null },
],
)
expect(result).toEqual({ ok: false, code: 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN' })
})
it('takes per-item beslut amounts when the beslutsfil recorded them', () => {
const result = computeRefusedShares(
{ requested_total: 5000, decided_total: 3000, decided_at: '2026-08-20' },
[
{ id: 'i1', invoice_id: INVOICE_A, requested_amount: 3000, decided_amount: 3000 },
{ id: 'i2', invoice_id: INVOICE_B, requested_amount: 2000, decided_amount: 0 },
],
)
expect(result).toMatchObject({
ok: true,
total: 2000,
shares: [
{ itemId: 'i1', refused: 0 },
{ itemId: 'i2', refused: 2000 },
],
})
})
it('refuses per-item amounts that do not reconcile with the request-level beslut', () => {
// Items say 4 000 refused, the header says 2 000: an inconsistent beslut
// must not book legs for a different sum than it displays.
const result = computeRefusedShares(
{ requested_total: 5000, decided_total: 3000, decided_at: '2026-08-20' },
[
{ id: 'i1', invoice_id: INVOICE_A, requested_amount: 3000, decided_amount: 1000 },
{ id: 'i2', invoice_id: INVOICE_B, requested_amount: 2000, decided_amount: 0 },
],
)
expect(result).toEqual({ ok: false, code: 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN' })
})
it('reports nothing refused on a fully approved beslut', () => {
const result = computeRefusedShares(
{ requested_total: 5000, decided_total: 5000, decided_at: '2026-08-20' },
[{ id: 'i1', invoice_id: INVOICE_A, requested_amount: 5000, decided_amount: 5000 }],
)
expect(result).toMatchObject({ ok: true, total: 0 })
})
})
describe('reclaimRotRutRefusal', () => {
const params = { requestId: REQUEST_ID, bookingDate: '2026-08-21' }
it('returns ROT_RUT_REQUEST_NOT_FOUND for an unknown request', async () => {
enqueue({ data: null })
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toEqual({ ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' })
expect(mockCreateReclaimEntry).not.toHaveBeenCalled()
})
it('refuses before booking when the beslut is not recorded', async () => {
enqueue({ data: makeRequestRow({ status: 'submitted', decided_total: null, decided_at: null }) })
enqueue({ data: [makeItem('i1', INVOICE_A, 5000, null)] })
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toMatchObject({ ok: false, code: 'ROT_RUT_RECLAIM_NO_BESLUT' })
expect(mockCreateReclaimEntry).not.toHaveBeenCalled()
})
it('refuses a request whose reclaim voucher and every leg are already applied', async () => {
enqueue({ data: makeRequestRow({ reclaim_journal_entry_id: 'je-old' }) })
enqueue({ data: [{ ...makeItem('i1', INVOICE_A, 5000, null), reclaimed_amount: 2000 }] })
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toMatchObject({ ok: false, code: 'ROT_RUT_RECLAIM_ALREADY_DONE' })
expect(mockCreateReclaimEntry).not.toHaveBeenCalled()
})
it('refuses a fully approved beslut: nothing to reclaim', async () => {
enqueue({ data: makeRequestRow({ status: 'paid', decided_total: 5000 }) })
enqueue({ data: [makeItem('i1', INVOICE_A, 5000, 5000)] })
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toMatchObject({ ok: false, code: 'ROT_RUT_RECLAIM_NOTHING_REFUSED' })
expect(mockCreateReclaimEntry).not.toHaveBeenCalled()
})
it('refuses an unknown split instead of guessing it', async () => {
enqueue({ data: makeRequestRow() })
enqueue({ data: [makeItem('i1', INVOICE_A, 3000, null), makeItem('i2', INVOICE_B, 2000, null)] })
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toMatchObject({ ok: false, code: 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN' })
expect(mockCreateReclaimEntry).not.toHaveBeenCalled()
})
it('refuses while an invoice is re-requested in a later live begäran', async () => {
// Avslag on A, invoice re-requested in B (submitted): the refused share
// stays at Skatteverket until B is decided.
enqueueOpen(makeRequestRow({ status: 'rejected', decided_total: 0 }), [makeItem('i1', INVOICE_A, 5000, null)], [
{ invoice_id: INVOICE_A, request: { id: 'req-b', status: 'submitted', company_id: 'company-1' } },
])
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toMatchObject({
ok: false,
code: 'ROT_RUT_RECLAIM_INVOICE_REREQUESTED',
details: { invoice_ids: [INVOICE_A] },
})
expect(findCall('rot_rut_payout_request_items', 'neq')).toEqual(['request_id', REQUEST_ID])
expect(mockCreateReclaimEntry).not.toHaveBeenCalled()
})
it('refuses an invoice without a verifikat: no 1513 debit exists to move', async () => {
enqueueOpen(makeRequestRow(), [makeItem('i1', INVOICE_A, 5000, null, { journal_entry_id: null })])
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toMatchObject({ ok: false, code: 'ROT_RUT_RECLAIM_INVOICE_NOT_BOOKED' })
expect(mockCreateReclaimEntry).not.toHaveBeenCalled()
})
it('refuses a credited or cancelled invoice and a non-SEK invoice', async () => {
enqueueOpen(makeRequestRow(), [makeItem('i1', INVOICE_A, 5000, null, { status: 'credited' })])
const credited = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(credited).toMatchObject({ ok: false, code: 'ROT_RUT_RECLAIM_INVOICE_NOT_OPEN' })
reset()
enqueueOpen(makeRequestRow(), [makeItem('i1', INVOICE_A, 5000, null, { currency: 'EUR' })])
const foreign = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(foreign).toMatchObject({ ok: false, code: 'ROT_RUT_RECLAIM_CURRENCY' })
expect(mockCreateReclaimEntry).not.toHaveBeenCalled()
})
it('books one voucher and reopens every invoice for its refused share', async () => {
// Two invoices, beslutsfil split: A fully approved (3 000), B refused (2 000).
enqueueOpen(makeRequestRow(), [
makeItem('i1', INVOICE_A, 3000, 3000, { total: 6000, paid_amount: 3000, deduction_total: 3000 }),
makeItem('i2', INVOICE_B, 2000, 0, { total: 4000, paid_amount: 2000, deduction_total: 2000 }),
])
enqueue({ data: { id: REQUEST_ID } }) // request CAS attach
enqueue({ data: { applied: true, remaining_amount: 2000, status: 'partially_paid' } }) // RPC for B
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toEqual({
ok: true,
journalEntryId: 'je-reclaim',
reclaimedTotal: 2000,
invoices: [
{
invoice_id: INVOICE_B,
invoice_number: '2026-002',
reclaimed_amount: 2000,
remaining_amount: 2000,
status: 'partially_paid',
},
],
})
// Only the refused invoice gets a leg.
expect(mockCreateReclaimEntry).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', {
requestId: REQUEST_ID,
requestName: 'RUT 2026-08',
deductionType: 'rut',
bookingDate: '2026-08-21',
legs: [{ invoiceId: INVOICE_B, invoiceNumber: '2026-002', amount: 2000 }],
})
// Request CAS on reclaim_journal_entry_id IS NULL.
const requestUpdate = findCall('rot_rut_payout_requests', 'update')?.[0] as Record<string, unknown>
expect(requestUpdate).toMatchObject({ reclaim_journal_entry_id: 'je-reclaim' })
expect(findCall('rot_rut_payout_requests', 'is')).toEqual(['reclaim_journal_entry_id', null])
// Invoice reopened through the atomic RPC: the database validates the
// amount and derives remaining/status; the caller names the share only.
expect(mockSupabase.rpc).toHaveBeenCalledTimes(1)
expect(mockSupabase.rpc).toHaveBeenCalledWith('apply_rot_rut_reclaim_invoice', {
p_item_id: 'i2',
p_invoice_id: INVOICE_B,
p_company_id: 'company-1',
p_reclaimed_amount: 2000,
})
expect(findCalls('invoices', 'update')).toHaveLength(0)
})
it('resumes a reclaim whose voucher exists but whose invoice legs were not applied', async () => {
// Voucher + marker on the request landed, then the RPC failed for B.
// The second call books nothing, skips the CAS, and applies B only.
// Full avslag on both invoices: A's leg applied (marker 3 000), B's not.
enqueueOpen(makeRequestRow({ reclaim_journal_entry_id: 'je-reclaim', status: 'rejected', decided_total: 0 }), [
{ ...makeItem('i1', INVOICE_A, 3000, null, { total: 6000, paid_amount: 3000, deduction_total: 3000 }), reclaimed_amount: 3000 },
makeItem('i2', INVOICE_B, 2000, null, { total: 4000, paid_amount: 2000, deduction_total: 2000 }),
])
enqueue({ data: { applied: true, remaining_amount: 2000, status: 'partially_paid' } }) // apply for B
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toMatchObject({
ok: true,
journalEntryId: 'je-reclaim',
reclaimedTotal: 2000,
invoices: [{ invoice_id: INVOICE_B, reclaimed_amount: 2000, remaining_amount: 2000, status: 'partially_paid' }],
})
expect(mockCreateReclaimEntry).not.toHaveBeenCalled()
expect(findCalls('rot_rut_payout_requests', 'update')).toHaveLength(0)
expect(mockSupabase.rpc).toHaveBeenCalledTimes(1)
expect(mockSupabase.rpc).toHaveBeenCalledWith('apply_rot_rut_reclaim_invoice', expect.objectContaining({ p_item_id: 'i2' }))
})
it('reports ROT_RUT_RECLAIM_ALREADY_DONE once every leg carries its marker', async () => {
enqueueOpen(makeRequestRow({ reclaim_journal_entry_id: 'je-reclaim' }), [
{ ...makeItem('i1', INVOICE_A, 5000, null), reclaimed_amount: 2000 },
])
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toMatchObject({
ok: false,
code: 'ROT_RUT_RECLAIM_ALREADY_DONE',
details: { journal_entry_id: 'je-reclaim' },
})
expect(mockCreateReclaimEntry).not.toHaveBeenCalled()
expect(mockSupabase.rpc).not.toHaveBeenCalled()
})
it('surfaces a failed invoice leg as an update-stage error and leaves the voucher standing', async () => {
enqueueOpen(makeRequestRow(), [makeItem('i1', INVOICE_A, 5000, null)])
enqueue({ data: { id: REQUEST_ID } })
enqueue({ data: null, error: { message: 'deadlock detected' } })
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toMatchObject({ ok: false, kind: 'error', stage: 'update' })
expect(mockCreateReclaimEntry).toHaveBeenCalledTimes(1)
})
it('reopens a full avslag on a never-paid customer share as sent', async () => {
enqueueOpen(makeRequestRow({ status: 'rejected', decided_total: 0 }), [
makeItem('i1', INVOICE_A, 5000, null, { status: 'sent', paid_amount: 0 }),
])
enqueue({ data: { id: REQUEST_ID } })
enqueue({ data: { applied: true, remaining_amount: 10000, status: 'sent' } })
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toMatchObject({
ok: true,
reclaimedTotal: 5000,
invoices: [{ invoice_id: INVOICE_A, reclaimed_amount: 5000, remaining_amount: 10000, status: 'sent' }],
})
})
it('reports what the RPC derived for the reopened invoice, never its own arithmetic', async () => {
// The database derives remaining/status (NULL paid_amount on a paid
// invoice counts the customer share as paid there); the service relays it.
enqueueOpen(makeRequestRow(), [
makeItem('i1', INVOICE_A, 5000, null, { status: 'paid', paid_amount: null }),
])
enqueue({ data: { id: REQUEST_ID } })
enqueue({ data: { applied: true, remaining_amount: '2000.00', status: 'partially_paid' } })
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toMatchObject({
ok: true,
reclaimedTotal: 2000,
invoices: [{ invoice_id: INVOICE_A, reclaimed_amount: 2000, remaining_amount: 2000, status: 'partially_paid' }],
})
})
it('reports a lost CAS as ROT_RUT_RECLAIM_RACE and never unbooks', async () => {
enqueueOpen(makeRequestRow(), [makeItem('i1', INVOICE_A, 5000, null)])
enqueue({ data: null }) // CAS lost: 0 rows
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toMatchObject({
ok: false,
code: 'ROT_RUT_RECLAIM_RACE',
details: { journal_entry_id: 'je-reclaim', request_id: REQUEST_ID },
})
expect(findCalls('invoices', 'update')).toHaveLength(0)
})
it('surfaces an engine failure as a book-stage error without touching any row', async () => {
enqueueOpen(makeRequestRow(), [makeItem('i1', INVOICE_A, 5000, null)])
mockCreateReclaimEntry.mockRejectedValue(new Error('Bokföringen är låst'))
const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params)
expect(outcome).toMatchObject({ ok: false, kind: 'error', stage: 'book' })
expect(findCalls('rot_rut_payout_requests', 'update')).toHaveLength(0)
})
})
+20 -4
View File
@@ -12,8 +12,14 @@
* drifted (#2248: the kontantmetod cut-off compared payments against the
* gross total). It now lives here and in exactly one SQL twin:
*
* invoices_remaining_amount_guard (migration 20260817191708)
* remaining_amount = GREATEST(0, ROUND(total - paid_amount - deduction_total, 2))
* invoices_derive_remaining_amount (migrations 20260817191708, 20260907160000)
* remaining_amount = GREATEST(0, ROUND(total - paid_amount - deduction_total
* + deduction_reclaimed_total, 2))
*
* deduction_reclaimed_total is the part of the deduction Skatteverket refused
* and that a rot_rut_reclaim voucher moved back onto the customer (debit 1510
* / credit 1513): from then on it IS the customer's to pay, while the invoice
* document keeps the deduction it was issued with.
*
* Change both or neither. The guard floors at zero because it persists the
* column; the functions here return the signed value and each writer applies
@@ -32,10 +38,18 @@ export interface CustomerShareInvoice {
* Null, undefined and 0 all mean "no deduction".
*/
deduction_total?: number | null
/**
* The part of the deduction Skatteverket refused and that was booked back
* onto the customer (rot_rut_reclaim). Positive magnitude, never above the
* deduction (CHECK invoices_deduction_reclaimed_total_check). Null,
* undefined and 0 all mean "nothing reclaimed".
*/
deduction_reclaimed_total?: number | null
}
/**
* What the customer owes on the invoice: total minus the ROT/RUT deduction.
* What the customer owes on the invoice: total minus the ROT/RUT deduction,
* plus whatever part of that deduction Skatteverket later refused.
*
* The deduction follows the sign of the total, so a credited ROT invoice
* (total -25 000, deduction_total 7 500) owes the customer -17 500 back and
@@ -47,7 +61,9 @@ export function invoiceCustomerShare(invoice: CustomerShareInvoice): number {
// `!(x > 0)` also catches NaN: a non-numeric deduction reads as none rather
// than poisoning every downstream amount.
if (!(deduction > 0)) return invoice.total
return roundOre(invoice.total - Math.sign(invoice.total) * deduction)
const reclaimedRaw = Math.abs(invoice.deduction_reclaimed_total ?? 0)
const reclaimed = reclaimedRaw > 0 ? Math.min(reclaimedRaw, deduction) : 0
return roundOre(invoice.total - Math.sign(invoice.total) * (deduction - reclaimed))
}
/**
@@ -67,6 +67,7 @@ export const INVOICE_ENGINE_SOURCE_TYPES = [
'credit_note',
'reminder_fee',
'rot_rut_payout',
'rot_rut_reclaim',
'storno',
'correction',
] as const
+14
View File
@@ -104,6 +104,7 @@ export type RotRutBlockerCode =
| 'PRICE_BELOW_MINIMUM'
| 'DEDUCTION_EXCEEDS_PAYMENT'
| 'ZERO_DEDUCTION'
| 'DEDUCTION_RECLAIMED'
export interface RotRutBlocker {
invoice_id: string
@@ -188,6 +189,19 @@ export function evaluateInvoiceForFile(
const items = invoice.items ?? []
const typeLines = items.filter((i) => isDeductionLine(i, type))
const otherType: DeductionType = type === 'rot' ? 'rut' : 'rot'
// Skatteverket refused (part of) this invoice's deduction and the refused
// share was booked back onto the customer (rot_rut_reclaim). The buyer now
// pays it, so a new begäran for the same kronor would claim from
// Skatteverket what the customer already owes: a double collection. The
// invoice becomes requestable again only when the reclaim voucher is
// reversed (syncRotRutReclaimAfterReversal clears the column).
if ((invoice.deduction_reclaimed_total ?? 0) > 0) {
return block(
'DEDUCTION_RECLAIMED',
'Skatteverket har nekat avdraget och det nekade beloppet är bokfört som kundfordran. Fakturan kan inte begäras igen så länge den bokningen står.',
)
}
const otherLines = items.filter((i) => isDeductionLine(i, otherType))
if (typeLines.length === 0) {
+173
View File
@@ -0,0 +1,173 @@
/**
* Match an income bank row to the ROT/RUT begäran Skatteverket paid with it.
*
* One writer for two callers: the dashboard route
* POST /api/transactions/[id]/match-rot-rut-payout and the MCP commit
* executor for settle_rot_rut_payout (gnubok_settle_rot_rut_payout). Both
* used to need the same pre-flight (income, SEK, not already booked, the
* row's own cash account) before the settle service; keeping it here means a
* guard added for one surface is a guard on the other.
*
* Amount, 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). Several begäran in
* one transfer (#2239) book ONE voucher with one 1513 credit per begäran.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import {
settleRotRutPayoutRequest,
settleRotRutPayoutRequestSet,
type SettleRotRutPayoutErrorCode,
type SettledRotRutPayoutRequest,
} from '@/lib/invoices/rot-rut-settle'
import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry'
import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked'
export type MatchRotRutPayoutErrorCode =
| SettleRotRutPayoutErrorCode
| 'TX_CATEGORIZE_TX_NOT_FOUND'
| 'ROT_RUT_MATCH_NOT_INCOME'
| 'ROT_RUT_MATCH_CURRENCY'
| 'ROT_RUT_MATCH_TX_ALREADY_LINKED'
export type MatchRotRutPayoutOutcome =
| {
ok: true
journalEntryId: string
amount: number
fullyPaid: boolean
/** Every begäran the voucher settled (one for the single shape). */
requests: SettledRotRutPayoutRequest[]
/** The single begäran, when exactly one was matched. */
request?: SettledRotRutPayoutRequest
}
| { ok: false; kind: 'code'; code: MatchRotRutPayoutErrorCode; details?: Record<string, unknown> }
/** At stage 'update' the voucher is posted: journalEntryId names it (see the settle service). */
| { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update'; journalEntryId?: string }
export interface MatchRotRutPayoutParams {
transactionId: string
/** 1..n begäran; duplicates collapse. */
requestIds: string[]
}
export async function matchTransactionToRotRutPayout(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: MatchRotRutPayoutParams,
log: Logger,
): Promise<MatchRotRutPayoutOutcome> {
const payoutRequestIds = [...new Set(params.requestIds)]
if (payoutRequestIds.length === 0) {
return { ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' }
}
// transaction_voucher_links rides along: a row bulk-booked into a
// samlingsverifikat carries journal_entry_id = NULL and must still refuse.
const { data: transactionRow, error: fetchTxError } = await supabase
.from('transactions')
.select(
'id, date, amount, currency, journal_entry_id, cash_account_id, transaction_voucher_links(journal_entry_id, role)',
)
.eq('id', params.transactionId)
.eq('company_id', companyId)
.single()
if (fetchTxError || !transactionRow) {
return { ok: false, kind: 'code', code: 'TX_CATEGORIZE_TX_NOT_FOUND' }
}
const { transaction_voucher_links: junctionLinks, ...transaction } = transactionRow as {
id: string
date: string
amount: number
currency: string | null
journal_entry_id: string | null
cash_account_id: string | null
transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null
}
if (!(transaction.amount > 0)) {
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_MATCH_NOT_INCOME',
details: { amount: transaction.amount },
}
}
if ((transaction.currency || 'SEK').toUpperCase() !== 'SEK') {
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_MATCH_CURRENCY',
details: { currency: transaction.currency },
}
}
// Only a LIVE (posted) pointer or a bank_line junction row blocks: a
// pointer left behind by a storno reads as "utan koppling" in the UI and
// must stay matchable (same predicate as link-journal-entry, issue #988).
if (
hasBankLineJunctionRow(junctionLinks) ||
(await hasLiveJournalEntryLink(supabase, companyId, transaction.journal_entry_id))
) {
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_MATCH_TX_ALREADY_LINKED',
details: { existingJournalEntryId: transaction.journal_entry_id },
}
}
// Debit the cash account THIS transaction belongs to, never a company-wide
// default (mirrors match-supplier-invoice).
const bankAccount = await resolveSettlementAccount(
supabase,
companyId,
transaction.cash_account_id,
log,
)
// 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: params.transactionId,
previousJournalEntryId: transaction.journal_entry_id,
}
if (payoutRequestIds.length === 1) {
const outcome = await settleRotRutPayoutRequest(supabase, userId, companyId, {
requestId: payoutRequestIds[0],
...settleParams,
})
if (!outcome.ok) return outcome
return {
ok: true,
journalEntryId: outcome.journalEntryId,
amount: outcome.amount,
fullyPaid: outcome.fullyPaid,
requests: [outcome.request],
request: outcome.request,
}
}
const outcome = await settleRotRutPayoutRequestSet(supabase, userId, companyId, {
requestIds: payoutRequestIds,
...settleParams,
})
if (!outcome.ok) return outcome
return {
ok: true,
journalEntryId: outcome.journalEntryId,
amount: outcome.amount,
fullyPaid: true,
requests: outcome.requests,
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Business-state mirror of a reversed rot_rut_reclaim voucher.
*
* The reclaim (lib/invoices/rot-rut-reclaim.ts) moves Skatteverkets refused
* share from 1513 onto the customer (1510) and reopens the invoices for it.
* When that voucher is reversed (booked on the wrong date, or Skatteverket
* granted the share on omprövning), the GL is restored by the storno but the
* invoice rows and the begäran would still say "reclaimed": the invoice open
* for a share nobody owes, the begäran unreclaimable forever
* (ROT_RUT_RECLAIM_ALREADY_DONE), the file blocker DEDUCTION_RECLAIMED stuck.
* Called from reverseEntry() next to the payment sync, and kept in its own
* module so the engine can import it without a cycle (the reclaim service
* imports the entries builder, which imports the engine).
*
* Every leg is one atomic, idempotent RPC (revert_rot_rut_reclaim_invoice):
* the item marker (reclaimed_amount) is the amount handed back and is
* cleared in the same transaction as the invoice row, so a re-run after a
* failure reverts only the legs still carrying a marker. The request's
* reclaim_journal_entry_id is cleared LAST and only when every leg
* succeeded, so a later run can still find the begäran by the reversed
* voucher and finish the job.
*
* A customer payment that already covered the reclaimed share is NOT undone
* (that voucher stands): the invoice then reads paid with an over-collected
* 1510, which is the honest state of the ledger after that sequence.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
const log = createLogger('invoices/rot-rut-reclaim-reversal')
interface ReclaimedItemRow {
id: string
invoice_id: string
reclaimed_amount: number | string | null
}
export async function syncRotRutReclaimAfterReversal(
supabase: SupabaseClient,
companyId: string,
reclaimJournalEntryId: string,
): Promise<void> {
const { data: request, error: requestError } = await supabase
.from('rot_rut_payout_requests')
.select('id')
.eq('company_id', companyId)
.eq('reclaim_journal_entry_id', reclaimJournalEntryId)
.maybeSingle()
if (requestError) {
log.error('reclaim reversal: failed to find the begäran', requestError as Error, {
reclaimJournalEntryId,
})
return
}
if (!request) return
const { data: itemRows, error: itemsError } = await supabase
.from('rot_rut_payout_request_items')
.select('id, invoice_id, reclaimed_amount')
.eq('request_id', request.id)
if (itemsError) {
log.error('reclaim reversal: failed to load items', itemsError as Error, {
payoutRequestId: request.id,
})
return
}
for (const item of (itemRows ?? []) as ReclaimedItemRow[]) {
if (item.reclaimed_amount == null) continue
const { error: revertError } = await supabase.rpc('revert_rot_rut_reclaim_invoice', {
p_item_id: item.id,
p_invoice_id: item.invoice_id,
p_company_id: companyId,
})
if (revertError) {
// Keep the request link: the next reversal sync (or a support re-run)
// finds the begäran by the reversed voucher and completes this leg.
log.error('reclaim reversal: invoice leg failed, request link kept for retry', revertError as Error, {
invoiceId: item.invoice_id,
itemId: item.id,
payoutRequestId: request.id,
reclaimJournalEntryId,
})
return
}
}
const { error: requestUpdateError } = await supabase
.from('rot_rut_payout_requests')
.update({ reclaim_journal_entry_id: null, reclaimed_at: null })
.eq('company_id', companyId)
.eq('id', request.id)
.eq('reclaim_journal_entry_id', reclaimJournalEntryId)
if (requestUpdateError) {
log.error('reclaim reversal: request reset failed', requestUpdateError as Error, {
payoutRequestId: request.id,
})
return
}
log.info('rot/rut reclaim reversed: invoices closed again', {
payoutRequestId: request.id,
reclaimJournalEntryId,
})
}
+472
View File
@@ -0,0 +1,472 @@
/**
* Reclaim the share of a ROT/RUT begäran that Skatteverket refused.
*
* Under fakturamodellen the deduction is a fordran on Skatteverket (1513)
* from the day the invoice is issued. A beslut that refuses an ärende, fully
* (avslag) or in part, does not make that fordran disappear: the buyer owes
* the refused share (HUSFL 2009:194; swedish-invoice-compliance section 8:
* "SKV denies: Debit 1510, Credit 1513, re-invoice customer"). Before this
* service the request was only marked rejected / partially_paid and the
* refused kronor stayed on 1513 while the invoice read as paid.
*
* What one call does, for one begäran:
* 1. Works out the refused share per invoice from the recorded beslut:
* requested_amount minus decided_amount per item. A full avslag
* (decided_total 0) refuses every item; a single-item begäran needs no
* per-item split; a multi-item begäran whose beslut was recorded as one
* total (PATCH partially_paid) is refused with SPLIT_UNKNOWN: the split
* is Skatteverket's, never guessed (import the beslutsfil).
* 2. Books ONE voucher: debit 1510 / credit 1513 per invoice
* (createRotRutReclaimEntry, source_type rot_rut_reclaim).
* 3. CAS-attaches it to the request (reclaim_journal_entry_id IS NULL), so
* two concurrent reclaims cannot both stand; the partial unique index
* journal_entries_rot_rut_reclaim_live_unique backs this at the journal.
* 4. Reopens every invoice for its refused share: deduction_reclaimed_total
* grows, remaining_amount and status are derived INSIDE the RPC
* apply_rot_rut_reclaim_invoice from the same formula as the SQL INSERT
* guard (the database owns the accounting values; the caller only names
* the refused share, which the RPC validates against the locked item,
* request and invoice rows). Status goes back to partially_paid (the
* customer already paid their share) or sent/overdue.
*
* Refusals before any write: no beslut, nothing refused, already reclaimed,
* unknown split, an invoice without a verifikat (no 1513 debit exists to
* move: kontantmetod invoice never paid, or deferred booking), a cancelled or
* credited invoice, a non-SEK invoice (1513 is a kronor account).
*
* The invoice document is left as issued: the customer got an invoice with
* a deduction on it, and that stays true; what changes is who owes the
* refused share. A separate follow-up (påminnelse) tells the customer.
*
* The voucher 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 { createRotRutReclaimEntry, type RotRutReclaimLeg } from '@/lib/bookkeeping/rot-rut-entries'
import { roundOre } from '@/lib/money'
import { createLogger } from '@/lib/logger'
const log = createLogger('invoices/rot-rut-reclaim')
export interface ReclaimRotRutRefusalParams {
requestId: string
/** Entry date of the reclaim voucher (the beslut date, or today). */
bookingDate: string
}
export type ReclaimRotRutErrorCode =
| 'ROT_RUT_REQUEST_NOT_FOUND'
| 'ROT_RUT_SETTLE_INVALID_STATE'
| 'ROT_RUT_RECLAIM_NO_BESLUT'
| 'ROT_RUT_RECLAIM_NOTHING_REFUSED'
| 'ROT_RUT_RECLAIM_ALREADY_DONE'
| 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN'
| 'ROT_RUT_RECLAIM_INVOICE_NOT_BOOKED'
| 'ROT_RUT_RECLAIM_INVOICE_NOT_OPEN'
| 'ROT_RUT_RECLAIM_CURRENCY'
| 'ROT_RUT_RECLAIM_INVOICE_REREQUESTED'
| 'ROT_RUT_RECLAIM_RACE'
export interface ReclaimedInvoice {
invoice_id: string
invoice_number: string | null
reclaimed_amount: number
remaining_amount: number
status: string
}
export type ReclaimRotRutRefusalOutcome =
| {
ok: true
journalEntryId: string
reclaimedTotal: number
invoices: ReclaimedInvoice[]
}
| { ok: false; kind: 'code'; code: ReclaimRotRutErrorCode; details?: Record<string, unknown> }
| { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update' }
/** Invoice statuses a refused share can reopen. */
const REOPENABLE_INVOICE_STATUSES = ['paid', 'partially_paid', 'sent', 'overdue'] as const
interface RequestRow {
id: string
name: string
deduction_type: 'rot' | 'rut'
status: string
requested_total: number | string
decided_total: number | string | null
decided_at: string | null
reclaim_journal_entry_id: string | null
}
interface InvoiceRow {
id: string
invoice_number: string | null
status: string
currency: string | null
total: number | string
paid_amount: number | string | null
deduction_total: number | string | null
deduction_reclaimed_total: number | string | null
journal_entry_id: string | null
document_type: string | null
}
interface ItemRow {
id: string
invoice_id: string
requested_amount: number | string
decided_amount: number | string | null
reclaimed_amount: number | string | null
invoice: InvoiceRow | null
}
export interface RefusedShare {
itemId: string
invoiceId: string
refused: number
}
/**
* Pure: the refused share per item from the recorded beslut, or the reason
* it cannot be known. Exported for the overview page, which shows "nekat att
* bokföra" per request without booking anything.
*/
export function computeRefusedShares(
request: Pick<RequestRow, 'requested_total' | 'decided_total' | 'decided_at'>,
items: Array<Pick<ItemRow, 'id' | 'invoice_id' | 'requested_amount' | 'decided_amount'>>,
):
| { ok: true; shares: RefusedShare[]; total: number }
| { ok: false; code: 'ROT_RUT_RECLAIM_NO_BESLUT' | 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN' } {
if (!request.decided_at || request.decided_total == null) {
return { ok: false, code: 'ROT_RUT_RECLAIM_NO_BESLUT' }
}
const decidedTotal = roundOre(Number(request.decided_total))
const requestedTotal = roundOre(Number(request.requested_total))
const fullRefusal = decidedTotal <= 0
const shares: RefusedShare[] = []
for (const item of items) {
const requested = roundOre(Number(item.requested_amount))
let decided: number
if (fullRefusal) {
decided = 0
} else if (item.decided_amount != null) {
decided = roundOre(Number(item.decided_amount))
} else if (items.length === 1) {
// One ärende: the request total IS the item's beslut.
decided = decidedTotal
} else {
return { ok: false, code: 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN' }
}
const refused = roundOre(Math.max(0, Math.min(requested, requested - decided)))
shares.push({ itemId: item.id, invoiceId: item.invoice_id, refused })
}
// The per-item shares ARE what gets booked, so they must reconcile with the
// request-level beslut to the öre. Items decided at less than the header
// says (or more) mean the recorded beslut is inconsistent: refuse, never
// book legs that sum to something else than the refused total.
const total = roundOre(shares.reduce((sum, s) => sum + s.refused, 0))
const refusedByHeader = roundOre(Math.max(0, requestedTotal - decidedTotal))
if (Math.abs(total - refusedByHeader) > 0.005) {
return { ok: false, code: 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN' }
}
return { ok: true, shares, total }
}
/**
* Invoices of this begäran that sit in ANOTHER begäran Skatteverket has not
* refused or the company has not cancelled. Avslag → new file is the normal
* retry (payout-requests/[id]/route.ts): while the invoice is re-requested,
* the refused share is being reviewed again and must not be booked onto the
* customer (skeptic #2397 C2). Returns the invoice ids, or the raw error.
*/
export async function findRerequestedInvoiceIds(
supabase: SupabaseClient,
companyId: string,
requestId: string,
invoiceIds: string[],
): Promise<{ ids: string[]; error: unknown }> {
if (invoiceIds.length === 0) return { ids: [], error: null }
const { data, error } = await supabase
.from('rot_rut_payout_request_items')
.select('invoice_id, request:rot_rut_payout_requests!inner(id, status, company_id)')
.eq('request.company_id', companyId)
.neq('request_id', requestId)
.in('invoice_id', invoiceIds)
.not('request.status', 'in', '("cancelled","rejected")')
if (error) return { ids: [], error }
const ids = [...new Set(((data ?? []) as Array<{ invoice_id: string }>).map((row) => row.invoice_id))]
return { ids, error: null }
}
export async function reclaimRotRutRefusal(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: ReclaimRotRutRefusalParams,
): Promise<ReclaimRotRutRefusalOutcome> {
const { data: request, error: fetchError } = await supabase
.from('rot_rut_payout_requests')
.select(
'id, name, deduction_type, status, requested_total, decided_total, decided_at, reclaim_journal_entry_id',
)
.eq('company_id', companyId)
.eq('id', params.requestId)
.maybeSingle()
if (fetchError) return { ok: false, kind: 'error', error: fetchError, stage: 'fetch' }
if (!request) return { ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' }
const req = request as RequestRow
if (req.status === 'cancelled') {
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_SETTLE_INVALID_STATE',
details: { status: req.status, reason: 'En avbruten begäran har inget beslut att bokföra.' },
}
}
// A begäran that already carries its reclaim voucher is not refused
// outright: a failure between the voucher and the per-invoice writes leaves
// legs whose item marker (reclaimed_amount) is still NULL, and re-running
// completes exactly those legs through the idempotent RPC (resume mode).
// Only when every leg is applied is the call ROT_RUT_RECLAIM_ALREADY_DONE.
const resumeJournalEntryId = req.reclaim_journal_entry_id
const { data: itemRows, error: itemsError } = await supabase
.from('rot_rut_payout_request_items')
.select(
'id, invoice_id, requested_amount, decided_amount, reclaimed_amount, invoice:invoices(id, invoice_number, status, currency, total, paid_amount, deduction_total, deduction_reclaimed_total, journal_entry_id, document_type)',
)
.eq('request_id', req.id)
if (itemsError) return { ok: false, kind: 'error', error: itemsError, stage: 'fetch' }
const items = (itemRows ?? []) as unknown as ItemRow[]
const computed = computeRefusedShares(req, items)
if (!computed.ok) {
return { ok: false, kind: 'code', code: computed.code, details: { request_id: req.id } }
}
const refusedShares = computed.shares.filter((share) => share.refused > 0)
if (refusedShares.length === 0 || computed.total <= 0) {
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_RECLAIM_NOTHING_REFUSED',
details: { requested_total: Number(req.requested_total), decided_total: Number(req.decided_total) },
}
}
// Resume mode: only the legs whose marker is still unset are pending.
const shares = resumeJournalEntryId
? refusedShares.filter((share) => {
const item = items.find((row) => row.id === share.itemId)
return item != null && item.reclaimed_amount == null
})
: refusedShares
if (resumeJournalEntryId && shares.length === 0) {
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_RECLAIM_ALREADY_DONE',
details: { journal_entry_id: resumeJournalEntryId },
}
}
// An invoice Skatteverket is reviewing again (a later, live begäran) keeps
// its refused share on 1513 until that beslut lands.
const rerequested = await findRerequestedInvoiceIds(
supabase,
companyId,
req.id,
shares.map((share) => share.invoiceId),
)
if (rerequested.error) {
return { ok: false, kind: 'error', error: rerequested.error, stage: 'fetch' }
}
if (rerequested.ids.length > 0) {
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_RECLAIM_INVOICE_REREQUESTED',
details: { invoice_ids: rerequested.ids },
}
}
// Every invoice must be able to carry the fordran before anything books.
const legs: Array<RotRutReclaimLeg & { item: ItemRow; invoice: InvoiceRow }> = []
for (const share of shares) {
const item = items.find((row) => row.id === share.itemId)!
const invoice = item.invoice
if (!invoice) {
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_RECLAIM_INVOICE_NOT_OPEN',
details: { invoice_id: share.invoiceId, reason: 'invoice row missing' },
}
}
if ((invoice.currency || 'SEK').toUpperCase() !== 'SEK') {
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_RECLAIM_CURRENCY',
details: { invoice_id: invoice.id, currency: invoice.currency },
}
}
if (!(REOPENABLE_INVOICE_STATUSES as readonly string[]).includes(invoice.status)) {
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_RECLAIM_INVOICE_NOT_OPEN',
details: { invoice_id: invoice.id, status: invoice.status },
}
}
// No verifikat means no 1513 debit exists for this invoice: nothing to
// move (kontantmetod invoice never paid, deferred booking not yet booked).
if (!invoice.journal_entry_id) {
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_RECLAIM_INVOICE_NOT_BOOKED',
details: { invoice_id: invoice.id },
}
}
const deduction = roundOre(Number(invoice.deduction_total ?? 0))
const alreadyReclaimed = roundOre(Number(invoice.deduction_reclaimed_total ?? 0))
const headroom = roundOre(deduction - alreadyReclaimed)
if (share.refused > headroom + 0.005) {
// The beslut refuses more than the invoice ever carried on 1513: a data
// inconsistency, not something to book around.
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_RECLAIM_NOTHING_REFUSED',
details: {
invoice_id: invoice.id,
refused: share.refused,
deduction_total: deduction,
deduction_reclaimed_total: alreadyReclaimed,
reason: 'refused share exceeds the deduction booked on 1513',
},
}
}
legs.push({
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
amount: share.refused,
item,
invoice,
})
}
let journalEntryId: string
if (resumeJournalEntryId) {
journalEntryId = resumeJournalEntryId
log.warn('rot/rut reclaim resuming: voucher exists, completing unapplied invoice legs', {
payoutRequestId: req.id,
journalEntryId,
pendingInvoiceIds: legs.map((leg) => leg.invoiceId),
})
} else {
// The voucher is the accounting record: engine failure must block.
try {
const entry = await createRotRutReclaimEntry(supabase, companyId, userId, {
requestId: req.id,
requestName: req.name,
deductionType: req.deduction_type,
bookingDate: params.bookingDate,
legs: legs.map((leg) => ({
invoiceId: leg.invoiceId,
invoiceNumber: leg.invoiceNumber,
amount: leg.amount,
})),
})
journalEntryId = entry.id
} catch (engineError) {
return { ok: false, kind: 'error', error: engineError, stage: 'book' }
}
// CAS on reclaim_journal_entry_id IS NULL: a concurrent reclaim must not
// reopen the invoices twice. The loser's voucher is caught by the partial
// unique index before it posts; this guard covers the request row itself.
const { data: attached, error: attachError } = await supabase
.from('rot_rut_payout_requests')
.update({ reclaim_journal_entry_id: journalEntryId, reclaimed_at: new Date().toISOString() })
.eq('company_id', companyId)
.eq('id', req.id)
.is('reclaim_journal_entry_id', null)
.select('id')
.maybeSingle()
if (attachError) {
log.error('rot/rut reclaim entry booked but request update failed', attachError as Error, {
journalEntryId,
payoutRequestId: req.id,
})
return { ok: false, kind: 'error', error: attachError, stage: 'update' }
}
if (!attached) {
log.error('rot/rut reclaim entry booked but request was reclaimed concurrently', undefined, {
journalEntryId,
payoutRequestId: req.id,
})
return {
ok: false,
kind: 'code',
code: 'ROT_RUT_RECLAIM_RACE',
details: { journal_entry_id: journalEntryId, request_id: req.id },
}
}
}
// Reopen every invoice for its refused share. Each leg is one atomic,
// idempotent RPC (apply_rot_rut_reclaim_invoice): the database locks the
// item, request and invoice, validates the amount against all three,
// derives remaining_amount and status from the one formula, and sets the
// item marker; a set marker is a no-op. A failure here is retryable by
// calling again: the voucher stands (never unbook) and the resume path
// above completes the missing legs.
const reopened: ReclaimedInvoice[] = []
for (const leg of legs) {
const invoice = leg.invoice
const { data: applied, error: applyError } = await supabase.rpc('apply_rot_rut_reclaim_invoice', {
p_item_id: leg.item.id,
p_invoice_id: invoice.id,
p_company_id: companyId,
p_reclaimed_amount: leg.amount,
})
if (applyError) {
log.error('rot/rut reclaim booked but invoice reopen failed (retry the reclaim to resume)', applyError as Error, {
journalEntryId,
payoutRequestId: req.id,
invoiceId: invoice.id,
reclaimedAmount: leg.amount,
})
return { ok: false, kind: 'error', error: applyError, stage: 'update' }
}
const result = (applied ?? {}) as { applied?: boolean; remaining_amount?: number | string; status?: string }
if (result.applied !== true) {
// Marker already set by a concurrent resume: that call reported the leg.
log.warn('rot/rut reclaim leg already applied', { itemId: leg.item.id, invoiceId: invoice.id })
continue
}
reopened.push({
invoice_id: invoice.id,
invoice_number: invoice.invoice_number,
reclaimed_amount: leg.amount,
remaining_amount: roundOre(Number(result.remaining_amount ?? 0)),
status: result.status ?? invoice.status,
})
}
const reclaimedTotal = roundOre(legs.reduce((sum, leg) => sum + leg.amount, 0))
log.info('rot/rut refused share reclaimed', {
userId,
payoutRequestId: req.id,
journalEntryId,
reclaimedTotal,
invoiceCount: reopened.length,
})
return { ok: true, journalEntryId, reclaimedTotal, invoices: reopened }
}
+9 -5
View File
@@ -88,8 +88,12 @@ export type SettleRotRutPayoutOutcome =
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' }
/**
* 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'
@@ -343,7 +347,7 @@ export async function settleRotRutPayoutRequest(
journalEntryId,
payoutRequestId: params.requestId,
})
return { ok: false, kind: 'error', error: updateError, stage: 'update' }
return { ok: false, kind: 'error', error: updateError, stage: 'update', journalEntryId }
}
if (!updated) {
// The loser's voucher already exists (immutable per BFL): say so loudly
@@ -436,7 +440,7 @@ export type SettleRotRutPayoutSetOutcome =
amount: number
}
| { ok: false; kind: 'code'; code: SettleRotRutPayoutErrorCode; details?: Record<string, unknown> }
| { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update' }
| { 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
@@ -555,7 +559,7 @@ export async function settleRotRutPayoutRequestSet(
payoutRequestId: leg.request.id,
settledRequestIds: settled.map((request) => request.id),
})
return { ok: false, kind: 'error', error: updateError, stage: 'update' }
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, {
+95
View File
@@ -115,6 +115,7 @@ import {
type BatchAllocationResult,
} from '@/lib/invoices/clear-settled-batch-allocations'
import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry'
import { matchTransactionToRotRutPayout } from '@/lib/invoices/rot-rut-match-transaction'
import {
completeInboxItemsForBookedTransaction,
resolveVoucherLinkedEntryIds,
@@ -380,6 +381,85 @@ type ExecutorResult = {
partialPostedIds?: Record<string, string>
}
/**
* settle_rot_rut_payout (gnubok_settle_rot_rut_payout): book Skatteverkets
* ROT/RUT utbetalning from the bank row against its begäran (one or several,
* #2239) through the same writer as the dashboard match route. The stage
* already checked income/SEK/unlinked/sum; the writer re-checks everything
* under its own CAS guards, so a race between approval and a manual match
* ends in a coded refusal, never a second 1513 credit.
*/
async function commitSettleRotRutPayout(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const transactionId = params.transaction_id as string | undefined
const requestIds = Array.isArray(params.request_ids)
? (params.request_ids as unknown[]).filter((id): id is string => typeof id === 'string')
: []
if (!transactionId || requestIds.length === 0) {
return { error: 'transaction_id and request_ids are required', status: 400 }
}
const outcome = await matchTransactionToRotRutPayout(
supabase,
userId,
companyId,
{ transactionId, requestIds },
log,
)
if (!outcome.ok) {
if (outcome.kind === 'code') {
const entry = getErrorEntry(outcome.code)
// A race or a failed row link happens AFTER the voucher posted: the op
// lands in failed_partial with the voucher id so nobody books it twice.
const postedId =
outcome.details && typeof outcome.details.journal_entry_id === 'string'
? (outcome.details.journal_entry_id as string)
: null
return {
error: entry?.message_en ?? outcome.code,
errorCode: outcome.code,
status: entry?.httpStatus ?? 500,
data: outcome.details as Record<string, unknown> | undefined,
...(postedId ? { partialPostedIds: { journal_entry_id: postedId } } : {}),
}
}
const message = outcome.error instanceof Error ? outcome.error.message : 'rot/rut payout settle failed'
// stage 'update' = the voucher posted and the request row did not absorb
// it: failed_partial with the voucher id, never a plain retryable 500
// (a retry would hit journal_entries_rot_rut_payout_live_unique).
return {
error: `${message} (stage: ${outcome.stage})`,
status: 500,
...(outcome.journalEntryId ? { partialPostedIds: { journal_entry_id: outcome.journalEntryId } } : {}),
}
}
// Audit-trail entry (ids only, no amounts or counterparty PII), same shape
// as commitMatchBatchAllocate.
log.info('settle_rot_rut_payout committed', {
companyId,
operationType: 'settle_rot_rut_payout',
transactionId,
journalEntryId: outcome.journalEntryId,
requestCount: outcome.requests.length,
})
return {
data: {
transaction_id: transactionId,
journal_entry_id: outcome.journalEntryId,
amount: outcome.amount,
request_ids: outcome.requests.map((request) => request.id),
request_statuses: outcome.requests.map((request) => request.status),
},
}
}
async function commitCategorizeTransaction(
supabase: SupabaseClient,
userId: string,
@@ -5147,6 +5227,18 @@ async function commitCreditInvoice(
if (!['sent', 'paid', 'overdue'].includes(original.status)) {
return { error: 'Only sent, paid, or overdue invoices can be credited', status: 400 }
}
// A refused ROT/RUT share booked onto the customer (rot_rut_reclaim) moved
// kronor from 1513 to 1510 after issue; the credit note reverses the
// issue-time split and would leave both accounts wrong. Reverse the reclaim
// first (same guard as the dashboard and v1 credit routes).
if (Number((original as { deduction_reclaimed_total?: number | null }).deduction_reclaimed_total ?? 0) > 0) {
const entry = getErrorEntry('INVOICE_CREDIT_ROT_RUT_RECLAIMED')
return {
error: entry?.message_en ?? 'Reverse the ROT/RUT reclaim voucher before crediting the invoice',
errorCode: 'INVOICE_CREDIT_ROT_RUT_RECLAIMED',
status: entry?.httpStatus ?? 400,
}
}
const today = new Date().toISOString().split('T')[0]
const creditNoteNumber = `KR-${original.invoice_number}`
@@ -7355,6 +7447,9 @@ async function commitPendingOperationInner(
case 'match_transaction_invoice':
result = await commitMatchTransactionInvoice(supabase, userId, companyId, pendingOp.params)
break
case 'settle_rot_rut_payout':
result = await commitSettleRotRutPayout(supabase, userId, companyId, pendingOp.params)
break
case 'link_invoice_voucher':
result = await commitLinkInvoiceVoucher(supabase, userId, companyId, pendingOp.params)
break
+4
View File
@@ -64,6 +64,10 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// ── Medium: reversible booking ─────────────────────────────────────
categorize_transaction: 'medium',
match_transaction_invoice: 'medium',
// Skatteverkets ROT/RUT utbetalning matched to its begäran: one bank row
// booked debit 19xx / credit 1513 and linked. Storno-reversible like the
// other bank-row matches, so the same tier as match_transaction_invoice.
settle_rot_rut_payout: 'medium',
// Link an existing posted verifikat as payment for an invoice. Reversible by
// deleting the invoice_payments row and reverting invoice status; no journal
// entry is created or modified. Sits next to match_transaction_invoice