Files
accounted/lib/invoices/__tests__/rot-rut-rules.test.ts
T
62c6fc44fe fix(invoices): article pre-fills ROT/RUT and kundkort personnummer covers the claim (#1634)
* fix(invoices): article pre-fills ROT/RUT and kundkort personnummer covers the claim

Two gaps reported by a user invoicing RUT work:

- Picking an article with a housework_type (arbetstypskod) left the line's
  skattereduktion on 'Ingen': the editor never fetched the field. applyArticle
  now derives deduction_type from the code's Skatteverket list (disjoint ROT/
  RUT lists, new deductionTypeForWorkType helper) and sets work_type, with the
  same overwrite semantics as description/price: an article without a code
  clears the deduction so a material article never keeps claiming one.
  'Spara som artikel' round-trips the code back onto the created article.

- The customer card's personnummer was never used for the ROT/RUT claim; the
  user had to retype it per invoice. The browser only ever sees ciphertext or
  a mask, so the fix is a server-side fallback in buildInvoiceWriteData:
  typed > stored draft > kundkort. The kundkort value is decrypted, expanded
  to 12 digits (new expandPersonnummerTo12, century inference incl. '+' and
  samordningsnummer), Luhn-validated, and encrypted into the invoice; invalid
  or unreadable values fall through to the existing 'Personnummer krävs'
  error. The editor drops the required-mark and hints that the number comes
  from the kundkort when one exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): gate the kundkort personnummer fallback on individual customers

ROT/RUT is a privatperson deduction; customers.personal_number is
individual-only in the Zod schemas but not in the DB, so a stray value on a
business row must never be claimed on implicitly. Typed values unaffected.
Raised by the compliance review bot on #1634.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:06:05 +02:00

383 lines
14 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,
deductionSekConverter,
deductionToSek,
deductionTypeForWorkType,
type ItemForDeduction,
type ValidateInvoiceItem,
} from '../rot-rut-rules'
// Mirrors the warning-text formatter in rot-rut-rules.ts, so the expected
// strings stay correct regardless of which group separator the ICU build picks.
const sv = (n: number): string =>
n.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
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('computeDeduction: base is labor cost INCLUDING VAT (HUSFL 6-9 §§)', () => {
it('Skatteverket worked example: 18 000 kr arbetskostnad @ 25% = 22 500 inkl. moms, ROT 30% = 6 750', () => {
const item: ItemForDeduction = {
unit_price: 18000,
quantity: 1,
deduction_type: 'rot',
vat_rate: 25,
}
expect(computeDeduction(item)).toBe(6750)
})
it('RUT 50% of labor incl. moms: 1 000 kr @ 25% = 1 250 → 625', () => {
const item: ItemForDeduction = {
unit_price: 1000,
quantity: 1,
deduction_type: 'rut',
vat_rate: 25,
}
expect(computeDeduction(item)).toBe(625)
})
it('respects the line rate: 1 000 kr @ 12% = 1 120 → ROT 336', () => {
const item: ItemForDeduction = {
unit_price: 1000,
quantity: 1,
deduction_type: 'rot',
vat_rate: 12,
}
expect(computeDeduction(item)).toBe(336)
})
it('vat_rate 0, null and undefined all mean momsfri labor (base = line total)', () => {
const base: ItemForDeduction = { unit_price: 10000, quantity: 1, deduction_type: 'rot' }
expect(computeDeduction({ ...base, vat_rate: 0 })).toBe(3000)
expect(computeDeduction({ ...base, vat_rate: null })).toBe(3000)
expect(computeDeduction(base)).toBe(3000)
})
it('reproduces the stored per-line vat_amount rounding before applying the percent', () => {
// 333.33 @ 25%: stored vat_amount = round2(83.3325) = 83.33, so the base
// is 416.66 (not 416.6625) and RUT 50% = 208.33.
const item: ItemForDeduction = {
unit_price: 333.33,
quantity: 1,
deduction_type: 'rut',
vat_rate: 25,
}
expect(computeDeduction(item)).toBe(208.33)
})
it('quantity > 1 with VAT: 20 × 500 kr @ 25% = 12 500 inkl. → ROT 3 750', () => {
const item: ItemForDeduction = {
unit_price: 500,
quantity: 20,
deduction_type: 'rot',
vat_rate: 25,
}
expect(computeDeduction(item)).toBe(3750)
})
})
describe('computeInvoiceDeductionTotal', () => {
it('sums on the inkl.-moms base when rates are present', () => {
const items: ItemForDeduction[] = [
{ unit_price: 18000, quantity: 1, deduction_type: 'rot', vat_rate: 25 }, // 6 750
{ unit_price: 1000, quantity: 1, deduction_type: 'rut', vat_rate: 25 }, // 625
{ unit_price: 2000, quantity: 1, vat_rate: 25 }, // not flagged
]
expect(computeInvoiceDeductionTotal(items)).toBe(7375)
})
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('deductionSekConverter / deductionToSek', () => {
it('SEK (or no context) is an identity conversion', () => {
expect(deductionToSek(3000)).toBe(3000)
expect(deductionToSek(3000, { currency: 'SEK' })).toBe(3000)
expect(deductionToSek(3000, { currency: null })).toBe(3000)
expect(deductionToSek(3000, { currency: 'sek', exchangeRate: 99 })).toBe(3000)
})
it('converts with the booking rate, öre-rounded like the 1513 debit', () => {
expect(deductionToSek(625, { currency: 'EUR', exchangeRate: 11.4 })).toBe(7125)
expect(deductionToSek(2083.33, { currency: 'EUR', exchangeRate: 11.4 })).toBe(23749.96)
expect(deductionToSek(100, { currency: 'eur', exchangeRate: 11.4 })).toBe(1140)
})
it('returns null for a foreign currency with no usable rate', () => {
expect(deductionToSek(625, { currency: 'EUR' })).toBeNull()
expect(deductionToSek(625, { currency: 'EUR', exchangeRate: null })).toBeNull()
expect(deductionToSek(625, { currency: 'EUR', exchangeRate: 0 })).toBeNull()
expect(deductionToSek(625, { currency: 'EUR', exchangeRate: -1 })).toBeNull()
expect(deductionToSek(625, { currency: 'EUR', exchangeRate: Number.NaN })).toBeNull()
expect(deductionSekConverter({ currency: 'EUR' })).toBeNull()
})
})
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)
})
it('SEK cap warning wording is unchanged', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 200000, quantity: 1, deduction_type: 'rot' }, // 60 000 deduction
]
const expected =
`ROT-avdraget på denna faktura (${sv(60000)} kr) överstiger årsmaximum ${ROT_MAX.toLocaleString('sv-SE')} kr. ` +
'Kunden behöver kontrollera sitt återstående utrymme själv.'
expect(validateInvoice(items, true, true).warnings).toEqual([expected])
// Passing the currency explicitly must not change a character.
expect(validateInvoice(items, true, true, { currency: 'SEK' }).warnings).toEqual([expected])
})
})
describe('validateInvoice: foreign currency vs the kronor ceilings', () => {
it('warns when the SEK value breaches the cap even though the foreign figure does not', () => {
// 6 000 EUR avdrag looks tiny next to 50 000, but is 68 400 kr.
const items: ValidateInvoiceItem[] = [
{ unit_price: 20000, quantity: 1, deduction_type: 'rot' },
]
const result = validateInvoice(items, true, true, { currency: 'EUR', exchangeRate: 11.4 })
expect(result.warnings).toHaveLength(1)
expect(result.warnings[0]).toContain(`${sv(6000)} EUR = ${sv(68400)} kr`)
expect(result.warnings[0]).toContain('överstiger årsmaximum')
})
it('stays quiet when the SEK value is under the cap although the foreign figure is not', () => {
// 55 000 NOK avdrag is 49 500 kr: under the 50 000 kr ceiling.
const items: ValidateInvoiceItem[] = [
{ unit_price: 183333.33, quantity: 1, deduction_type: 'rot' },
]
const result = validateInvoice(items, true, true, { currency: 'NOK', exchangeRate: 0.9 })
expect(result.warnings).toHaveLength(0)
})
it('never labels a foreign amount "kr"', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 250000, quantity: 1, deduction_type: 'rut' }, // 125 000 EUR
]
const result = validateInvoice(items, true, true, { currency: 'EUR', exchangeRate: 11.4 })
expect(result.warnings[0]).toContain(`${sv(125000)} EUR`)
expect(result.warnings[0]).not.toContain(`(${sv(125000)} kr)`)
})
it('says the cap could not be checked when the invoice has no rate', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 20000, quantity: 1, deduction_type: 'rot' },
]
const result = validateInvoice(items, true, true, { currency: 'EUR' })
expect(result.errors).toHaveLength(0)
expect(result.warnings).toHaveLength(1)
expect(result.warnings[0]).toContain(`${sv(6000)} EUR`)
expect(result.warnings[0]).toContain('kan inte stämmas av')
expect(result.warnings[0]).toContain('saknar växelkurs')
expect(result.warnings[0]).not.toContain('överstiger')
})
it('reports ROT and RUT separately on the same foreign invoice', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 20000, quantity: 1, deduction_type: 'rot' }, // 6 000 EUR = 68 400 kr
{ unit_price: 20000, quantity: 1, deduction_type: 'rut' }, // 10 000 EUR = 114 000 kr
]
const result = validateInvoice(items, true, true, { currency: 'EUR', exchangeRate: 11.4 })
expect(result.warnings).toHaveLength(2)
expect(result.warnings[0]).toContain('ROT-avdraget')
expect(result.warnings[1]).toContain('RUT-avdraget')
})
})
describe('deductionTypeForWorkType', () => {
it('maps ROT codes to rot and RUT codes to rut', () => {
expect(deductionTypeForWorkType('BYGG')).toBe('rot')
expect(deductionTypeForWorkType('VVS')).toBe('rot')
expect(deductionTypeForWorkType('STAD')).toBe('rut')
// IT-tjänster moved from the rot list to rut 2026-07: the mapping must
// follow the lists, never a hardcoded copy.
expect(deductionTypeForWorkType('IT')).toBe('rut')
expect(deductionTypeForWorkType('TVATT')).toBe('rut')
})
it('maps unknown or absent codes to null', () => {
expect(deductionTypeForWorkType(null)).toBeNull()
expect(deductionTypeForWorkType(undefined)).toBeNull()
expect(deductionTypeForWorkType('')).toBeNull()
expect(deductionTypeForWorkType('SNICKERI')).toBeNull()
})
})