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:
co-authored by
Claude Fable 5
parent
d035d283ef
commit
638a25a11a
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user