Files
accounted/lib/invoices/__tests__/rot-rut-file.test.ts
T
MattssonandClaude Fable 5 638a25a11a 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>
2026-08-25 21:32:06 +02:00

878 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest'
import type { Invoice, InvoiceItem } from '@/types'
import { makeInvoice } from '@/tests/helpers'
import { encryptPersonnummer } from '@/lib/salary/personnummer'
import { proposeSendLines } from '@/lib/bookkeeping/propose-send-lines'
import {
buildRotRutFile,
evaluateInvoiceForFile,
isPastRequestDeadline,
normalizeBrfOrgNr,
} from '@/lib/invoices/rot-rut-file'
// Personnummer from Skatteverket's official example files (synthetic test
// identities published by the agency: never real people).
const PNR_A = '198406012388'
const PNR_B = '199604102393'
const TODAY = '2026-07-02'
function makeItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
return {
id: 'item-1',
invoice_id: 'invoice-1',
sort_order: 0,
description: 'Arbete',
quantity: 1,
unit: 'tim',
unit_price: 10000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
deduction_type: 'rot',
deduction_amount: 3000,
labor_hours: 25,
work_type: 'BYGG',
housing_designation: 'Stockholm Vasastan 1:23',
apartment_number: null,
brf_org_number: null,
created_at: '2026-06-01T00:00:00Z',
...overrides,
}
}
function makeRotInvoice(overrides: Partial<Invoice> = {}, items?: InvoiceItem[]): Invoice {
return makeInvoice({
status: 'paid',
paid_at: '2026-06-20T10:00:00Z',
deduction_total: 3000,
deduction_personnummer_encrypted: encryptPersonnummer(PNR_A),
deduction_personnummer_last4: PNR_A.slice(-4),
items: items ?? [makeItem()],
...overrides,
})
}
describe('buildRotRutFile: rot', () => {
it('produces a schema-shaped rot file for a paid invoice', () => {
const result = buildRotRutFile({
type: 'rot',
name: 'ROT 2026-07-02',
invoices: [makeRotInvoice()],
today: TODAY,
})
expect(result.blockers).toHaveLength(0)
expect(result.arenden).toHaveLength(1)
expect(result.requested_total).toBe(3000)
expect(result.file_name).toBe('rot_begaran_2026-07-02.xml')
const xml = result.xml!
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>')
expect(xml).toContain('xmlns:ns1="http://xmls.skatteverket.se/se/skatteverket/ht/begaran/6.0"')
expect(xml).toContain('xmlns:ns2="http://xmls.skatteverket.se/se/skatteverket/ht/komponent/begaran/6.0"')
expect(xml).toContain('<ns2:NamnPaBegaran>ROT 2026-07-02</ns2:NamnPaBegaran>')
expect(xml).toContain('<ns2:RotBegaran>')
expect(xml).toContain(`<ns2:Kopare>${PNR_A}</ns2:Kopare>`)
expect(xml).toContain('<ns2:BetalningsDatum>2026-06-20</ns2:BetalningsDatum>')
expect(xml).toContain('<ns2:PrisForArbete>12500</ns2:PrisForArbete>')
expect(xml).toContain('<ns2:BetaltBelopp>9500</ns2:BetaltBelopp>')
expect(xml).toContain('<ns2:BegartBelopp>3000</ns2:BegartBelopp>')
expect(xml).toContain('<ns2:Ovrigkostnad>0</ns2:Ovrigkostnad>')
expect(xml).toContain('<ns2:Fastighetsbeteckning>Stockholm Vasastan 1:23</ns2:Fastighetsbeteckning>')
expect(xml).toContain('<ns2:Bygg>')
expect(xml).toContain('<ns2:AntalTimmar>25</ns2:AntalTimmar>')
expect(xml).toContain('<ns2:Materialkostnad>0</ns2:Materialkostnad>')
})
it('is byte-identical for a plain SEK invoice (conversion is a no-op)', () => {
// Locks the SEK output: threading currency through the amount block must
// not move a single character for the overwhelmingly common case.
const expected = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<ns1:Begaran xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
+ ' xmlns:ns1="http://xmls.skatteverket.se/se/skatteverket/ht/begaran/6.0"'
+ ' xmlns:ns2="http://xmls.skatteverket.se/se/skatteverket/ht/komponent/begaran/6.0">',
'\t<ns2:NamnPaBegaran>ROT 2026-07-02</ns2:NamnPaBegaran>',
'\t<ns2:RotBegaran>',
'\t\t<ns2:Arenden>',
`\t\t\t<ns2:Kopare>${PNR_A}</ns2:Kopare>`,
'\t\t\t<ns2:BetalningsDatum>2026-06-20</ns2:BetalningsDatum>',
'\t\t\t<ns2:PrisForArbete>12500</ns2:PrisForArbete>',
'\t\t\t<ns2:BetaltBelopp>9500</ns2:BetaltBelopp>',
'\t\t\t<ns2:BegartBelopp>3000</ns2:BegartBelopp>',
'\t\t\t<ns2:FakturaNr>F-2024001</ns2:FakturaNr>',
'\t\t\t<ns2:Ovrigkostnad>0</ns2:Ovrigkostnad>',
'\t\t\t<ns2:Fastighetsbeteckning>Stockholm Vasastan 1:23</ns2:Fastighetsbeteckning>',
'\t\t\t<ns2:UtfortArbete>',
'\t\t\t\t<ns2:Bygg>',
'\t\t\t\t\t<ns2:AntalTimmar>25</ns2:AntalTimmar>',
'\t\t\t\t\t<ns2:Materialkostnad>0</ns2:Materialkostnad>',
'\t\t\t\t</ns2:Bygg>',
'\t\t\t</ns2:UtfortArbete>',
'\t\t</ns2:Arenden>',
'\t</ns2:RotBegaran>',
'</ns1:Begaran>',
].join('\n')
const result = buildRotRutFile({
type: 'rot',
name: 'ROT 2026-07-02',
invoices: [makeRotInvoice()],
today: TODAY,
})
expect(result.xml).toBe(expected)
})
it('emits ärende elements in the XSD sequence order', () => {
const xml = buildRotRutFile({
type: 'rot',
name: 'Ordning',
invoices: [makeRotInvoice()],
today: TODAY,
}).xml!
const order = [
'Kopare',
'BetalningsDatum',
'PrisForArbete',
'BetaltBelopp',
'BegartBelopp',
'FakturaNr',
'Ovrigkostnad',
'Fastighetsbeteckning',
'UtfortArbete',
]
const positions = order.map((el) => xml.indexOf(`<ns2:${el}`))
for (const pos of positions) expect(pos).toBeGreaterThan(-1)
for (let i = 1; i < positions.length; i++) {
expect(positions[i]).toBeGreaterThan(positions[i - 1])
}
})
it('aggregates hours per work type and orders work elements per XSD', () => {
const items = [
makeItem({ id: 'i1', work_type: 'VVS', labor_hours: 3, line_total: 3000, vat_amount: 750, deduction_amount: 900 }),
makeItem({ id: 'i2', work_type: 'EL', labor_hours: 2, line_total: 2000, vat_amount: 500, deduction_amount: 600 }),
makeItem({ id: 'i3', work_type: 'VVS', labor_hours: 2.4, line_total: 1000, vat_amount: 250, deduction_amount: 300 }),
]
const xml = buildRotRutFile({
type: 'rot',
name: 'Aggregering',
invoices: [makeRotInvoice({}, items)],
today: TODAY,
}).xml!
// 3 + 2.4 h VVS → 5 (whole hours per XSD long)
expect(xml).toMatch(/<ns2:Vvs>\s*<ns2:AntalTimmar>5<\/ns2:AntalTimmar>/)
expect(xml).toMatch(/<ns2:El>\s*<ns2:AntalTimmar>2<\/ns2:AntalTimmar>/)
// El precedes Vvs in the XSD sequence
expect(xml.indexOf('<ns2:El>')).toBeLessThan(xml.indexOf('<ns2:Vvs>'))
})
it('uses lägenhetsnummer + normalized BRF orgnr for bostadsrätt', () => {
const items = [
makeItem({ housing_designation: null, apartment_number: '1101', brf_org_number: '769600-0000' }),
]
const xml = buildRotRutFile({
type: 'rot',
name: 'Brf',
invoices: [makeRotInvoice({}, items)],
today: TODAY,
}).xml!
expect(xml).not.toContain('Fastighetsbeteckning')
expect(xml).toContain('<ns2:LagenhetsNr>1101</ns2:LagenhetsNr>')
expect(xml).toContain('<ns2:BrfOrgNr>167696000000</ns2:BrfOrgNr>')
})
it('escapes XML special characters and clamps NamnPaBegaran to 16 chars', () => {
const items = [makeItem({ housing_designation: 'Gränby 1:2 & "Södra" <3' })]
const result = buildRotRutFile({
type: 'rot',
name: 'Väldigt långt namn på begäran som klipps',
invoices: [makeRotInvoice({ invoice_number: 'F<&>2026' }, items)],
today: TODAY,
})
const xml = result.xml!
expect(xml).toContain('Gränby 1:2 &amp; &quot;Södra&quot; &lt;3')
expect(xml).toContain('<ns2:FakturaNr>F&lt;&amp;&gt;2026</ns2:FakturaNr>')
const name = xml.match(/<ns2:NamnPaBegaran>(.*)<\/ns2:NamnPaBegaran>/)?.[1]
expect(name).toBe('Väldigt långt na')
})
})
describe('buildRotRutFile: rut', () => {
function makeRutInvoice(items: InvoiceItem[]): Invoice {
return makeRotInvoice(
{ deduction_personnummer_encrypted: encryptPersonnummer(PNR_B) },
items,
)
}
it('wraps ärenden in HushallBegaran and accepts IT-tjänster', () => {
const items = [
makeItem({ deduction_type: 'rut', work_type: 'IT', labor_hours: 4, housing_designation: null, deduction_amount: 5000 }),
]
const xml = buildRotRutFile({
type: 'rut',
name: 'RUT juni',
invoices: [makeRutInvoice(items)],
today: TODAY,
}).xml!
expect(xml).toContain('<ns2:HushallBegaran>')
expect(xml).not.toContain('RotBegaran')
expect(xml).toMatch(/<ns2:ItTjanster>\s*<ns2:AntalTimmar>4<\/ns2:AntalTimmar>/)
// No property elements for rut
expect(xml).not.toContain('Fastighetsbeteckning')
})
it('reports schablontjänster as Utfort without hours', () => {
const items = [
makeItem({ deduction_type: 'rut', work_type: 'TVATT', labor_hours: null, housing_designation: null, deduction_amount: 250 }),
]
const xml = buildRotRutFile({
type: 'rut',
name: 'Schablon',
invoices: [makeRutInvoice(items)],
today: TODAY,
}).xml!
expect(xml).toMatch(/<ns2:TvattVidTvattinrattning>\s*<ns2:Utfort>true<\/ns2:Utfort>/)
expect(xml).not.toContain('AntalTimmar')
})
})
describe('foreign-currency invoices: kronor conversion', () => {
/**
* Begaran.xsd has no currency attribute: every belopp is an xs:long in
* kronor. The ledger already converts (BAS 1513 is debited in SEK), so the
* file has to convert with the same rate or the receivable can never clear.
*/
/** The ledger's own 1513 debit for this invoice, per öre. */
function ledger1513(invoice: Invoice): number {
const lines = proposeSendLines({ invoice, entityType: 'enskild_firma' })
return lines
.filter((l) => l.account_number === '1513')
.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
}
const EUR_RATE = 11.4
function makeEurRotInvoice(item: Partial<InvoiceItem>, overrides: Partial<Invoice> = {}): Invoice {
const merged = makeItem(item)
return makeRotInvoice(
{
currency: 'EUR',
exchange_rate: EUR_RATE,
subtotal: merged.line_total,
vat_amount: merged.vat_amount,
total: merged.line_total + merged.vat_amount,
...overrides,
},
[merged],
)
}
it('emits SEK whole kronor for a 3 000 EUR rot invoice', () => {
// 3 000 EUR arbete + 750 EUR moms = 3 750 EUR inkl. moms; avdrag 30% av
// inkl.-moms-arbetet = 1 125 EUR (HUSFL 6-9 §§), rate 11,40:
// PrisForArbete 34 200 + 8 550 = 42 750 kr
// BegartBelopp 1 125 × 11,40 = 12 825 kr
// BetaltBelopp 42 750 - 12 825 = 29 925 kr
const invoice = makeEurRotInvoice({
unit_price: 3000,
quantity: 1,
line_total: 3000,
vat_amount: 750,
deduction_amount: 1125,
labor_hours: 10,
})
const result = buildRotRutFile({ type: 'rot', name: 'EUR', invoices: [invoice], today: TODAY })
expect(result.blockers).toHaveLength(0)
const xml = result.xml!
expect(xml).toContain('<ns2:PrisForArbete>42750</ns2:PrisForArbete>')
expect(xml).toContain('<ns2:BegartBelopp>12825</ns2:BegartBelopp>')
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 (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', () => {
// The bug this test pins: a EUR avdrag booked to 1513 in kronor while
// the begäran asked Skatteverket for the raw EUR figure (read as kronor).
// 2 083,33 EUR arbete + 520,83 moms = 2 604,16 inkl.; 30% = 781,25 EUR
// = 8 906,25 kr at 11,40, whole-kronor 8 906 in the file.
const invoice = makeEurRotInvoice({
unit_price: 2083.33,
quantity: 1,
line_total: 2083.33,
vat_amount: 520.83,
deduction_amount: 781.25,
labor_hours: 8,
})
const result = buildRotRutFile({ type: 'rot', name: 'EUR 625', invoices: [invoice], today: TODAY })
expect(result.blockers).toHaveLength(0)
const xml = result.xml!
expect(xml).toContain('<ns2:BegartBelopp>8906</ns2:BegartBelopp>')
expect(xml).not.toContain('<ns2:BegartBelopp>781</ns2:BegartBelopp>')
expect(ledger1513(invoice)).toBe(8906.25)
expect(result.arenden[0].begart_belopp).toBe(8906)
})
it('MISSING_EXCHANGE_RATE rather than a guessed figure when the rate is absent', () => {
for (const rate of [null, 0, -1]) {
const invoice = makeEurRotInvoice(
{ unit_price: 3000, line_total: 3000, vat_amount: 750, deduction_amount: 900 },
{ exchange_rate: rate },
)
const result = evaluateInvoiceForFile('rot', invoice)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.blocker.code).toBe('MISSING_EXCHANGE_RATE')
expect(result.blocker.message).toContain('växelkurs')
}
}
})
it('refuses to build a file from a rate-less foreign invoice', () => {
const invoice = makeEurRotInvoice(
{ unit_price: 3000, line_total: 3000, vat_amount: 750, deduction_amount: 900 },
{ exchange_rate: null },
)
const result = buildRotRutFile({ type: 'rot', name: 'Utan kurs', invoices: [invoice], today: TODAY })
expect(result.xml).toBeNull()
expect(result.requested_total).toBe(0)
expect(result.blockers.map((b) => b.code)).toEqual(['MISSING_EXCHANGE_RATE'])
})
it('requested_total sums kronor, never mixed currencies', () => {
// One SEK invoice (3 000 kr avdrag) + one EUR invoice (900 EUR = 10 260 kr).
const sekInvoice = makeRotInvoice()
const eurInvoice = makeEurRotInvoice({
unit_price: 3000,
quantity: 1,
line_total: 3000,
vat_amount: 750,
deduction_amount: 900,
labor_hours: 10,
})
const result = buildRotRutFile({
type: 'rot',
name: 'Blandad valuta',
invoices: [sekInvoice, eurInvoice],
today: TODAY,
})
expect(result.blockers).toHaveLength(0)
expect(result.arenden.map((a) => a.begart_belopp)).toEqual([3000, 10260])
expect(result.requested_total).toBe(13260)
// The old scalar added 3 000 kr to 900 EUR and called it 3 900.
expect(result.requested_total).not.toBe(3900)
})
})
describe('eligibility blockers', () => {
it('NOT_PAID for unpaid invoices', () => {
const result = evaluateInvoiceForFile('rot', makeRotInvoice({ status: 'sent' }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('NOT_PAID')
})
it('MISSING_PAYMENT_DATE when paid without paid_at', () => {
const result = evaluateInvoiceForFile('rot', makeRotInvoice({ paid_at: null }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('MISSING_PAYMENT_DATE')
})
it('FUTURE_PAYMENT_DATE when the recorded payment is after today', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ paid_at: '2026-07-03T10:00:00Z' }),
{ today: TODAY },
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('FUTURE_PAYMENT_DATE')
})
it('NO_DEDUCTION_OF_TYPE when the invoice has no lines of the requested type', () => {
const result = evaluateInvoiceForFile('rut', makeRotInvoice())
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('NO_DEDUCTION_OF_TYPE')
})
it('NO_DEDUCTION_OF_TYPE points at the other type when the deduction is the other kind', () => {
// A rot invoice evaluated as rut must say "this is ROT", not just "no
// rut lines": the dialog's empty list gave no pointer at all (#1884).
const result = evaluateInvoiceForFile('rut', makeRotInvoice())
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.blocker.message).toContain('ROT')
expect(result.blocker.message).toContain('hanteras under ROT')
}
})
it('NO_DEDUCTION_OF_TYPE keeps the plain message when no deduction lines exist at all', () => {
const items = [makeItem({ deduction_type: null, work_type: null, deduction_amount: 0 })]
const result = evaluateInvoiceForFile('rot', makeRotInvoice({}, items))
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.blocker.code).toBe('NO_DEDUCTION_OF_TYPE')
expect(result.blocker.message).toBe('Fakturan har inga ROT-rader.')
}
})
it('DEDUCTION_TOTAL_MISSING when lines carry a deduction the header never recorded', () => {
// Older imports left deduction_total NULL/0 despite deduction lines:
// those invoices previously fell out of the candidate query entirely.
for (const headerTotal of [0, undefined]) {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ deduction_total: headerTotal }),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('DEDUCTION_TOTAL_MISSING')
}
})
it('DEDUCTION_TOTAL_MISSING wins over NOT_PAID (the missing header is why the status is stuck)', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ deduction_total: 0, status: 'partially_paid', remaining_amount: 3000 }),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('DEDUCTION_TOTAL_MISSING')
})
it('accepts partially_paid when the customer share is settled (older settlement paths)', () => {
// Customer share = total - deduction_total = 9 500; paid_amount covers it
// even though the status never flipped to paid.
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ status: 'partially_paid', remaining_amount: 0, paid_amount: 9500 }),
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.arende.begart_belopp).toBe(3000)
expect(result.value.arende.betalnings_datum).toBe('2026-06-20')
}
})
it('derives the customer share from header fields, not the stored remaining_amount', () => {
// payment-sync's storno path recomputes remaining_amount WITHOUT
// subtracting deduction_total (total - paid = 3 000 here), so the stored
// column can carry Skatteverkets share. The gate must key on
// total - paid_amount - deduction_total = 0 and accept anyway.
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ status: 'partially_paid', remaining_amount: 3000, paid_amount: 9500 }),
)
expect(result.ok).toBe(true)
})
// Same formatting as the blocker message (sv-SE uses NBSP thousands
// separators, so a typed-out literal would never match).
const svAmount = (n: number): string =>
n.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
it('NOT_PAID with the outstanding amount for a genuinely partial payment', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ status: 'partially_paid', remaining_amount: 4500, paid_amount: 5000 }),
)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.blocker.code).toBe('NOT_PAID')
expect(result.blocker.message).toContain('delbetald')
expect(result.blocker.message).toContain(`${svAmount(4500)} kr`)
}
})
it('reports the TRUE customer share even when remaining_amount is corrupted', () => {
// Stored remaining says 7 500 (payment-sync storno formula), but the
// customer share outstanding is 12 500 - 5 000 - 3 000 = 4 500: the
// message must not tell the user to collect Skatteverkets 3 000 kr.
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ status: 'partially_paid', remaining_amount: 7500, paid_amount: 5000 }),
)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.blocker.code).toBe('NOT_PAID')
expect(result.blocker.message).toContain(svAmount(4500))
expect(result.blocker.message).not.toContain(svAmount(7500))
}
})
it('MISSING_PAYMENT_DATE for a settled partially_paid invoice without paid_at', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ status: 'partially_paid', remaining_amount: 0, paid_amount: 9500, paid_at: null }),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('MISSING_PAYMENT_DATE')
})
it('MIXED_DEDUCTION_TYPES when rot and rut lines share an invoice', () => {
const items = [
makeItem(),
makeItem({ id: 'i2', deduction_type: 'rut', work_type: 'STAD', labor_hours: 2 }),
]
const result = evaluateInvoiceForFile('rot', makeRotInvoice({}, items))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('MIXED_DEDUCTION_TYPES')
})
it('MISSING_PERSONNUMMER without an encrypted personnummer', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ deduction_personnummer_encrypted: null }),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('MISSING_PERSONNUMMER')
})
it('PERSONNUMMER_UNREADABLE on undecryptable ciphertext', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ deduction_personnummer_encrypted: 'deadbeef' }),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('PERSONNUMMER_UNREADABLE')
})
it('MISSING_WORK_TYPE when a deduction line has no arbetstyp', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [makeItem({ work_type: null })]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('MISSING_WORK_TYPE')
})
it('INVALID_WORK_TYPE for IT flagged as rot (rut-only service)', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [makeItem({ work_type: 'IT' })]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('INVALID_WORK_TYPE')
})
it('MISSING_HOURS when a non-schablon line lacks labor hours', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [makeItem({ labor_hours: null })]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('MISSING_HOURS')
})
it('HOURS_OUT_OF_RANGE above 999 hours', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [makeItem({ labor_hours: 1200 })]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('HOURS_OUT_OF_RANGE')
})
it('MISSING_PROPERTY when a rot invoice has no property info', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [makeItem({ housing_designation: null })]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('MISSING_PROPERTY')
})
it('INVALID_BRF_ORGNR on a malformed BRF orgnr', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [
makeItem({ housing_designation: null, apartment_number: '1101', brf_org_number: '123' }),
]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('INVALID_BRF_ORGNR')
})
it('PRICE_BELOW_MINIMUM when arbetskostnaden rounds below 2 kr', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [
makeItem({ line_total: 1, vat_amount: 0, deduction_amount: 0.3, labor_hours: 1 }),
]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('PRICE_BELOW_MINIMUM')
})
it('ZERO_DEDUCTION when the deduction rounds to 0 kr', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [
makeItem({ line_total: 100, vat_amount: 25, deduction_amount: 0, labor_hours: 1 }),
]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('ZERO_DEDUCTION')
})
it('DEDUCTION_EXCEEDS_PAYMENT when begärt belopp exceeds what the buyer paid', () => {
// 100 kr work, 60 kr deduction → buyer paid 40 kr < 60 kr requested.
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [
makeItem({ line_total: 80, vat_amount: 20, deduction_amount: 60, labor_hours: 1 }),
]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('DEDUCTION_EXCEEDS_PAYMENT')
})
it('allows begärt belopp equal to betalt belopp (50 % rut)', () => {
const result = evaluateInvoiceForFile(
'rut',
makeRotInvoice({}, [
makeItem({
deduction_type: 'rut',
work_type: 'STAD',
line_total: 80,
vat_amount: 20,
deduction_amount: 50,
labor_hours: 2,
housing_designation: null,
}),
]),
)
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',
name: 'Blandat',
invoices: [makeRotInvoice(), makeRotInvoice({ status: 'sent', invoice_number: 'F-BAD' })],
today: TODAY,
})
expect(result.arenden).toHaveLength(1)
expect(result.blockers).toHaveLength(1)
expect(result.blockers[0].invoice_number).toBe('F-BAD')
expect(result.xml).not.toBeNull()
})
it('MIXED_PAYMENT_YEARS when one file spans more than one payment year', () => {
const result = buildRotRutFile({
type: 'rot',
name: 'Två år',
invoices: [
makeRotInvoice({ id: 'invoice-2026', invoice_number: 'F-2026' }),
makeRotInvoice({
id: 'invoice-2025',
invoice_number: 'F-2025',
paid_at: '2025-12-30T10:00:00Z',
}),
],
today: TODAY,
})
expect(result.arenden).toHaveLength(1)
expect(result.blockers).toEqual([
expect.objectContaining({ invoice_id: 'invoice-2025', code: 'MIXED_PAYMENT_YEARS' }),
])
})
it('TOO_MANY_CASES when a file contains more than 100 cases', () => {
// Reuse one encrypted synthetic personnummer. Creating 101 independent
// ciphertexts would benchmark the KDF rather than the file-size rule.
const baseInvoice = makeRotInvoice()
const invoices = Array.from({ length: 101 }, (_, index) => ({
...baseInvoice,
id: `invoice-${index + 1}`,
invoice_number: `F-${index + 1}`,
}))
const result = buildRotRutFile({
type: 'rot',
name: 'För många',
invoices,
today: TODAY,
})
expect(result.arenden).toHaveLength(100)
expect(result.blockers).toEqual([
expect.objectContaining({ invoice_id: 'invoice-101', code: 'TOO_MANY_CASES' }),
])
}, 30_000)
it('returns xml: null when nothing is eligible', () => {
const result = buildRotRutFile({
type: 'rot',
name: 'Tomt',
invoices: [makeRotInvoice({ status: 'sent' })],
today: TODAY,
})
expect(result.xml).toBeNull()
expect(result.requested_total).toBe(0)
})
})
describe('deadline + helpers', () => {
it('warns when the 31 January deadline has passed', () => {
const invoice = makeRotInvoice({ paid_at: '2024-12-30T00:00:00Z' })
const result = buildRotRutFile({ type: 'rot', name: 'Sen', invoices: [invoice], today: TODAY })
expect(result.warnings).toHaveLength(1)
expect(result.warnings[0]).toContain('31 januari')
})
it('isPastRequestDeadline boundary behavior', () => {
expect(isPastRequestDeadline('2025-06-01', '2026-01-31')).toBe(false)
expect(isPastRequestDeadline('2025-06-01', '2026-02-01')).toBe(true)
expect(isPastRequestDeadline('2026-01-15', '2026-07-02')).toBe(false)
})
it('normalizeBrfOrgNr handles 10/12 digits and rejects the rest', () => {
expect(normalizeBrfOrgNr('769600-0000')).toBe('167696000000')
expect(normalizeBrfOrgNr('167696000000')).toBe('167696000000')
expect(normalizeBrfOrgNr('76960')).toBeNull()
// 12 digits without the sekelsiffra 16 prefix is not a valid orgnr.
expect(normalizeBrfOrgNr('123456789012')).toBeNull()
})
it('matches the shape of Skatteverkets official rot example', () => {
// Mirror exempel_rot_3st.xml ärende 2: fastighet + one work type.
const items = [
makeItem({
work_type: 'GLAS_PLAT',
labor_hours: 4,
housing_designation: 'TEST 1:7',
line_total: 1600,
vat_amount: 400,
deduction_amount: 600,
}),
]
const xml = buildRotRutFile({
type: 'rot',
name: 'Exempel Rot',
invoices: [makeRotInvoice({}, items)],
today: TODAY,
}).xml!
expect(xml).toMatch(
/<ns2:Arenden>\s*<ns2:Kopare>\d{12}<\/ns2:Kopare>\s*<ns2:BetalningsDatum>\d{4}-\d{2}-\d{2}<\/ns2:BetalningsDatum>\s*<ns2:PrisForArbete>2000<\/ns2:PrisForArbete>\s*<ns2:BetaltBelopp>1400<\/ns2:BetaltBelopp>\s*<ns2:BegartBelopp>600<\/ns2:BegartBelopp>/,
)
expect(xml).toMatch(/<ns2:Fastighetsbeteckning>TEST 1:7<\/ns2:Fastighetsbeteckning>\s*<ns2:UtfortArbete>\s*<ns2:GlasPlatarbete>/)
})
})