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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-25 21:32:06 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d035d283ef
commit 638a25a11a
4 changed files with 149 additions and 6 deletions
+1
View File
@@ -1242,4 +1242,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+103 -2
View File
@@ -301,8 +301,9 @@ describe('foreign-currency invoices: kronor conversion', () => {
expect(xml).toContain('<ns2:BetaltBelopp>29925</ns2:BetaltBelopp>')
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('<ns2:PrisForArbete>125</ns2:PrisForArbete>')
expect(xml).toContain('<ns2:BetaltBelopp>63</ns2:BetaltBelopp>')
expect(xml).toContain('<ns2:BegartBelopp>62</ns2:BegartBelopp>')
})
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',
@@ -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.
+15 -4
View File
@@ -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',