From 638a25a11aad56cffd61a9c52749854baad9c290 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:32:06 +0200 Subject: [PATCH] fix(rot-rut): floor BegartBelopp so a half-krona deduction cannot exceed the cap (#1910) * fix(rot-rut): floor BegartBelopp so a half-krona deduction cannot exceed the cap The payout file is whole kronor and skattereduktionen is capped at a share of the work price (HUSFL: 50% RUT / 30% ROT). Math.round pushed an exact half-krona deduction up: 125 kr work -> 62,50 kr RUT became begart 63 with betalt 125 - 63 = 62, so the DEDUCTION_EXCEEDS_PAYMENT guard blocked a perfectly correct invoice. Every work price that is an odd number of kronor hits this. BegartBelopp now floors the ore-rounded sum: 62,50 -> 62, betalt 63, valid file. Flooring can never create begart > betalt (2*floor(D) is an integer <= pris <= round(pris) whenever the ledger deduction respects the cap), so the guard becomes a pure corruption check. Invoices that pass today are unchanged: round and floor only differ at fraction >= .5, and those were all blocked. The ore-rounding before the floor keeps float noise (62.499999...) from dropping a whole krona; a test pins it. Regression tests cover the 125-kr half-krona case end to end (evaluate, XML amounts, eligible list). Co-Authored-By: Claude Fable 5 * refactor(rot-rut): use roundOre helper for the pre-floor ore rounding check:guards naive-ore-round flags inline Math.round(x*100)/100; the sanctioned @/lib/money roundOre does the same with an EPSILON nudge. Co-Authored-By: Claude Fable 5 * fix(rot-rut): pin the ROT half-ore class and use truncateToWholeKronor Skeptic findings on the frozen head (fbc3bb0c8): 1. The 'previously-passing invoices are byte-identical' claim was false for ROT: at 30% the deduction sits far below the begart > betalt guard, so e.g. 500 kr labor + 125 moms (ROT 187,50) previously PASSED and emitted 188, exceeding both the statutory cap and the 1513 fordran. The floor now emits 187/438: deliberately 1 kr lower. A test pins the case, and DECISIONS.md states the real blast radius. 2. lib/money.ts already ships truncateToWholeKronor, documented as the amount rule for everything Skatteverket-bound, with the same ore-round-then-truncate semantics; use it instead of hand-rolling Math.floor(roundOre(...)). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + lib/invoices/__tests__/rot-rut-file.test.ts | 105 +++++++++++++++++- .../__tests__/rot-rut-service.test.ts | 30 +++++ lib/invoices/rot-rut-file.ts | 19 +++- 4 files changed, 149 insertions(+), 6 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 1696ddd1..4628aea8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1242,4 +1242,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-25] Ranged income statement sums period movements, not closing balances (skeptic refutation on PR #1909): with from_date > period_start the trial balance rolls pre-range P&L activity into opening columns, so closing-column sums are year-to-date mislabeled as the range (July revenue reported as Jan-Jul). generateIncomeStatement now passes periodMovements to buildIncomeStatementFromRows whenever fromDate is set, matching the resultatrapport convention; full-period behavior is byte-identical. from_date was also dropped from the v1 balance-sheet routes (a balansräkning is a cumulative position; ÅRL 3 kap): as_of/to_date only, matching the MCP tool. [2026-08-25] Issue #1870: skattekonto AGI seed reverted 2730 -> 2731 (salary side kept on 2731), not the alternative of moving SALARY_ACCOUNTS.AVGIFTER_LIABILITY to 2730: BAS 2026 defines 2731 as exactly the reported-but-unpaid arbetsgivaravgift liability (the accrual account is 2940), and the salary module's whole-krona/ore-residual logic (PR #1609, 2026-08-14 decisions) is built around 2731. The 20260519160000 migration's rationale mislabeled 2731 as the accrual account; a one-sided flip either way reintroduces the split. Historical 2730 debits since 2026-05-19 are left for per-company reclass verifikat, not repaired in-migration. [2026-08-25] The marketplace entry for the Accounted plugin is a git-subdir source (public repo URL + path claude-plugin), not the relative path ./claude-plugin: relative sources only resolve when the whole marketplace repo is cloned (Claude Code), while Claude.ai's Add-marketplace backend fetches the manifest and resolves each plugin source as a repository, which surfaced as 'Repository not accessible' on a public repo. git-subdir is also the form the plugin-directory catalog uses for monorepos. +[2026-08-25] ROT/RUT BegartBelopp truncates to whole kronor (truncateToWholeKronor), not half-up: the deduction is capped at 50%/30% of arbetskostnaden. At the RUT cap, half-up manufactured begart > betalt and blocked correct invoices; for ROT below the cap it over-requested past the cap and the 1513 fordran, so those files now ask 1 kr less (skeptic-verified on PR #1910). [2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent. diff --git a/lib/invoices/__tests__/rot-rut-file.test.ts b/lib/invoices/__tests__/rot-rut-file.test.ts index 665437af..47a96f13 100644 --- a/lib/invoices/__tests__/rot-rut-file.test.ts +++ b/lib/invoices/__tests__/rot-rut-file.test.ts @@ -301,8 +301,9 @@ describe('foreign-currency invoices: kronor conversion', () => { expect(xml).toContain('29925') expect(result.requested_total).toBe(12825) - // The begäran and the receivable must be the same claim. - expect(result.arenden[0].begart_belopp).toBe(Math.round(ledger1513(invoice))) + // The begäran and the receivable must be the same claim (whole kronor, + // floored: the file may never ask for more than the 1513 fordran). + expect(result.arenden[0].begart_belopp).toBe(Math.floor(ledger1513(invoice))) }) it('asks for 8 906 kr, not 781, on the 781.25 EUR deduction case', () => { @@ -659,6 +660,106 @@ describe('eligibility blockers', () => { expect(result.ok).toBe(true) }) + it('floors a half-krona deduction instead of manufacturing begärt > betalt', () => { + // 100 kr work + 25 kr moms = 125 kr; rut 50 % = 62,50 kr. The file is + // whole kronor and the deduction may never exceed the 50 % cap, so the + // begäran must ask for 62 (floor), not 63 (half-up), which would exceed + // the cap and trip DEDUCTION_EXCEEDS_PAYMENT on a perfectly correct + // invoice. Regression for the 2026-08-25 support case. + const invoice = makeRotInvoice({}, [ + makeItem({ + deduction_type: 'rut', + work_type: 'STAD', + line_total: 100, + vat_amount: 25, + deduction_amount: 62.5, + labor_hours: 1, + housing_designation: null, + }), + ]) + + const evaluated = evaluateInvoiceForFile('rut', invoice) + expect(evaluated.ok).toBe(true) + if (evaluated.ok) { + expect(evaluated.value.arende.pris_for_arbete).toBe(125) + expect(evaluated.value.arende.begart_belopp).toBe(62) + expect(evaluated.value.arende.betalt_belopp).toBe(63) + } + + const xml = buildRotRutFile({ + type: 'rut', + name: 'Halvkrona', + invoices: [invoice], + today: TODAY, + }).xml! + expect(xml).toContain('125') + expect(xml).toContain('63') + expect(xml).toContain('62') + }) + + it('floors a ROT half-öre deduction that the old rounding pushed past the 30 % cap', () => { + // 500 kr labor + 125 moms = 625 kr; ROT 30 % = 187,50. Half-up rounding + // emitted 188, which exceeded both the statutory cap and the 1513 fordran + // (and, unlike the RUT-at-50 % case, passed the begärt > betalt guard). + // The floor emits 187/438. Skeptic finding on PR #1910: pins that the fix + // deliberately lowers this class of previously-passing ROT files by 1 kr. + const result = evaluateInvoiceForFile( + 'rot', + makeRotInvoice({}, [ + makeItem({ line_total: 500, vat_amount: 125, deduction_amount: 187.5, labor_hours: 5 }), + ]), + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.arende.pris_for_arbete).toBe(625) + expect(result.value.arende.begart_belopp).toBe(187) + expect(result.value.arende.betalt_belopp).toBe(438) + } + }) + + it('öre-rounds the deduction sum before flooring so float noise cannot drop a krona', () => { + // Three öre-level lines summing to exactly 63.00 in FP-land can land at + // 62.999999...; the floor must apply to the öre-rounded sum (63), not the + // raw float (62). + const result = evaluateInvoiceForFile( + 'rut', + makeRotInvoice({}, [ + makeItem({ + id: 'i1', + deduction_type: 'rut', + work_type: 'STAD', + line_total: 33.6, + vat_amount: 8.4, + deduction_amount: 21.0, + labor_hours: 1, + housing_designation: null, + }), + makeItem({ + id: 'i2', + deduction_type: 'rut', + work_type: 'STAD', + line_total: 33.6, + vat_amount: 8.4, + deduction_amount: 20.99, + labor_hours: 1, + housing_designation: null, + }), + makeItem({ + id: 'i3', + deduction_type: 'rut', + work_type: 'STAD', + line_total: 33.62, + vat_amount: 8.41, + deduction_amount: 21.01, + labor_hours: 1, + housing_designation: null, + }), + ]), + ) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value.arende.begart_belopp).toBe(63) + }) + it('collects blockers per invoice while still emitting eligible ones', () => { const result = buildRotRutFile({ type: 'rot', diff --git a/lib/invoices/__tests__/rot-rut-service.test.ts b/lib/invoices/__tests__/rot-rut-service.test.ts index 30315ca1..6b7ae600 100644 --- a/lib/invoices/__tests__/rot-rut-service.test.ts +++ b/lib/invoices/__tests__/rot-rut-service.test.ts @@ -144,6 +144,36 @@ describe('listRotRutCandidates', () => { expect(result.eligible[0].begart_belopp).toBe(6250) }) + it('lists a half-krona RUT deduction as eligible with begärt floored', async () => { + // 125 kr work incl moms → 62,50 kr deduction. Whole-kronor file amounts + // must floor to 62 (the 50 % cap); half-up rounding used to block this + // invoice as DEDUCTION_EXCEEDS_PAYMENT (63 > 62). + const invoice = makeRotRow( + { deduction_total: 62.5 }, + [ + makeRotItem({ + deduction_type: 'rut', + work_type: 'STAD', + line_total: 100, + vat_amount: 25, + deduction_amount: 62.5, + labor_hours: 1, + housing_designation: null, + }), + ], + ) + const result = await listRotRutCandidates(mockedSupabase([invoice], [invoice], []), 'company-1', 'rut', TODAY) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.blocked).toHaveLength(0) + expect(result.eligible).toHaveLength(1) + expect(result.eligible[0]).toMatchObject({ + pris_for_arbete: 125, + begart_belopp: 62, + }) + }) + it('accepts partially_paid with remaining 0 and blocks a genuine partial with the amount', async () => { // Drop path 2: the customer paid their share but the status never // flipped to paid (older settlement paths). remaining_amount decides. diff --git a/lib/invoices/rot-rut-file.ts b/lib/invoices/rot-rut-file.ts index f1d89ca9..50703b56 100644 --- a/lib/invoices/rot-rut-file.ts +++ b/lib/invoices/rot-rut-file.ts @@ -1,4 +1,5 @@ import type { Invoice, InvoiceItem } from '@/types' +import { truncateToWholeKronor } from '@/lib/money' import { decryptPersonnummer } from '@/lib/salary/personnummer' import { deductionSekConverter, SCHABLON_WORK_TYPES, type DeductionType } from './rot-rut-rules' @@ -377,7 +378,15 @@ export function evaluateInvoiceForFile( const prisForArbete = Math.round( typeLines.reduce((sum, l) => sum + toSek(l.line_total ?? 0) + toSek(l.vat_amount ?? 0), 0), ) - const begartBelopp = Math.round( + // BegartBelopp rounds DOWN (truncateToWholeKronor, the Skatteverket-bound + // amount rule): skattereduktionen is capped at a share of the work price + // (HUSFL: 50 % RUT / 30 % ROT), so a deduction with öre, 62,50 or 187,50, + // must become 62 / 187, never 63 / 188. Half-up rounding requested more + // than the cap and the 1513 fordran: at the exact RUT cap it manufactured + // begärt > betalt and blocked a correct invoice; below the cap (ROT 30 %) + // it over-requested by up to a krona. The helper öre-rounds before + // truncating so float noise (62.499999...) cannot drop a whole krona. + const begartBelopp = truncateToWholeKronor( typeLines.reduce((sum, l) => sum + toSek(l.deduction_amount ?? 0), 0), ) const betaltBelopp = prisForArbete - begartBelopp @@ -390,9 +399,11 @@ export function evaluateInvoiceForFile( return block('ZERO_DEDUCTION', 'Fakturans ROT/RUT-avdrag är 0 kr: det finns inget belopp att begära.') } // The buyer must have paid at least as much as is being requested - // (skattereduktionen är max 50 % av arbetskostnaden). Independent rounding - // of pris/begärt could otherwise even push BetaltBelopp negative, which - // Skatteverkets schema rejects outright. + // (skattereduktionen är max 50 % av arbetskostnaden). With begärt floored, + // any invoice whose ledger deduction respects the cap passes by + // construction (2*floor(D) is an integer <= pris <= round(pris)); this + // guard now only catches corrupted deduction data, where BetaltBelopp + // could even go negative, which Skatteverkets schema rejects outright. if (begartBelopp > betaltBelopp) { return block( 'DEDUCTION_EXCEEDS_PAYMENT',