* 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>
383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
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()
|
||
})
|
||
})
|