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>
195 lines
6.3 KiB
TypeScript
195 lines
6.3 KiB
TypeScript
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)
|
||
})
|
||
})
|