Files
accounted/lib/invoices/__tests__/rot-rut-rules.test.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

195 lines
6.3 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 {
ROT_PERCENT,
RUT_PERCENT,
ROT_MAX,
RUT_MAX,
computeDeduction,
computeInvoiceDeductionTotal,
computeDeductionTotalsByKind,
validateInvoice,
type ItemForDeduction,
type ValidateInvoiceItem,
} from '../rot-rut-rules'
describe('rot-rut-rules: constants', () => {
it('uses the 2026 statutory rates', () => {
expect(ROT_PERCENT).toBe(0.30)
expect(RUT_PERCENT).toBe(0.50)
expect(ROT_MAX).toBe(50000)
expect(RUT_MAX).toBe(75000)
})
})
describe('computeDeduction', () => {
it('standard ROT: 10 000 kr labor → 3 000 kr deduction', () => {
const item: ItemForDeduction = {
unit_price: 10000,
quantity: 1,
deduction_type: 'rot',
}
expect(computeDeduction(item)).toBe(3000)
})
it('standard RUT: 5 000 kr labor → 2 500 kr deduction', () => {
const item: ItemForDeduction = {
unit_price: 5000,
quantity: 1,
deduction_type: 'rut',
}
expect(computeDeduction(item)).toBe(2500)
})
it('no deduction_type → 0', () => {
const item: ItemForDeduction = {
unit_price: 10000,
quantity: 1,
}
expect(computeDeduction(item)).toBe(0)
})
it('null deduction_type → 0', () => {
const item: ItemForDeduction = {
unit_price: 10000,
quantity: 1,
deduction_type: null,
}
expect(computeDeduction(item)).toBe(0)
})
it('negative or zero amount → 0', () => {
expect(computeDeduction({ unit_price: 0, quantity: 1, deduction_type: 'rot' })).toBe(0)
expect(computeDeduction({ unit_price: -100, quantity: 1, deduction_type: 'rut' })).toBe(0)
})
it('quantity > 1 with ROT', () => {
const item: ItemForDeduction = {
unit_price: 500,
quantity: 20, // 10 000 total
deduction_type: 'rot',
}
expect(computeDeduction(item)).toBe(3000)
})
it('rounds to two decimals', () => {
const item: ItemForDeduction = {
unit_price: 333.33,
quantity: 1,
deduction_type: 'rut', // 333.33 * 0.5 = 166.665 → 166.67 (banker's rounding off)
}
expect(computeDeduction(item)).toBe(166.67)
})
it('caps at line total even if percent goes off (defensive)', () => {
// The percent is < 1.0 so this is hypothetical, but the cap is part
// of the contract: assert it via a synthetic case where unit_price ×
// quantity happens to be tiny but the rounding step could overshoot.
const item: ItemForDeduction = {
unit_price: 0.01,
quantity: 1,
deduction_type: 'rut',
}
// 0.01 * 0.5 = 0.005 → rounds to 0.01 = line_total. OK, capped.
expect(computeDeduction(item)).toBe(0.01)
})
})
describe('computeInvoiceDeductionTotal', () => {
it('mixed: ROT line + non-eligible line: only ROT generates deduction', () => {
const items: ItemForDeduction[] = [
{ unit_price: 10000, quantity: 1, deduction_type: 'rot' },
{ unit_price: 2000, quantity: 1 }, // not flagged
]
expect(computeInvoiceDeductionTotal(items)).toBe(3000)
})
it('mixed ROT + RUT lines sum independently', () => {
const items: ItemForDeduction[] = [
{ unit_price: 10000, quantity: 1, deduction_type: 'rot' }, // 3 000
{ unit_price: 4000, quantity: 1, deduction_type: 'rut' }, // 2 000
]
expect(computeInvoiceDeductionTotal(items)).toBe(5000)
})
it('all non-eligible → 0', () => {
const items: ItemForDeduction[] = [
{ unit_price: 1000, quantity: 1 },
{ unit_price: 2000, quantity: 1 },
]
expect(computeInvoiceDeductionTotal(items)).toBe(0)
})
})
describe('computeDeductionTotalsByKind', () => {
it('separates ROT and RUT', () => {
const items: ItemForDeduction[] = [
{ unit_price: 10000, quantity: 1, deduction_type: 'rot' }, // 3 000
{ unit_price: 4000, quantity: 1, deduction_type: 'rut' }, // 2 000
{ unit_price: 2000, quantity: 1, deduction_type: 'rot' }, // 600
]
expect(computeDeductionTotalsByKind(items)).toEqual({ rot: 3600, rut: 2000 })
})
})
describe('validateInvoice', () => {
it('errors when ROT/RUT but personnummer missing', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 5000, quantity: 1, deduction_type: 'rut' },
]
const result = validateInvoice(items, false, true)
expect(result.errors).toContain('Personnummer krävs för ROT/RUT-avdrag.')
})
it('errors when ROT but housing_designation missing', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 5000, quantity: 1, deduction_type: 'rot' },
]
const result = validateInvoice(items, true, false)
expect(result.errors).toContain('Fastighetsbeteckning krävs för ROT-avdrag.')
})
it('RUT without housing_designation → no error (RUT does not require it)', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 5000, quantity: 1, deduction_type: 'rut' },
]
const result = validateInvoice(items, true, false)
expect(result.errors).toHaveLength(0)
})
it('no deduction lines → no errors regardless of metadata', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 5000, quantity: 1 },
]
expect(validateInvoice(items, false, false).errors).toHaveLength(0)
})
it('warns about ROT cap when invoice alone exceeds 50 000', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 200000, quantity: 1, deduction_type: 'rot' }, // 60 000 deduction
]
const result = validateInvoice(items, true, true)
expect(result.errors).toHaveLength(0)
expect(result.warnings.length).toBeGreaterThan(0)
expect(result.warnings[0]).toMatch(/ROT/)
expect(result.warnings[0]).toMatch(/50/)
})
it('warns about RUT cap when invoice alone exceeds 75 000', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 200000, quantity: 1, deduction_type: 'rut' }, // 100 000 deduction
]
const result = validateInvoice(items, true, true)
expect(result.warnings.length).toBeGreaterThan(0)
expect(result.warnings[0]).toMatch(/RUT/)
expect(result.warnings[0]).toMatch(/75/)
})
it('no warning when total under cap', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 10000, quantity: 1, deduction_type: 'rot' }, // 3 000: well under cap
]
const result = validateInvoice(items, true, true)
expect(result.warnings).toHaveLength(0)
})
})