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:
co-authored by
Claude Fable 5.1
parent
5d735097a1
commit
fdcb7d937e
@@ -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({
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user