Files
accounted/lib/salary/__tests__/calculation-engine.test.ts
T
Mattsson 993b3f5962 Fix/remove disabled fields (#426)
* fix(dashboard): enable salary features with "Beta" badge for testing

* fix(errors): enhance Swedish error message patterns for better user feedback

fix(salary): update Nordea Personkonto handling in account encoding logic

* fix(agi): implement feature flag for AGI transmission and update button states

* fix(swedish-payroll): update youth rate eligibility criteria and enhance documentation

* fix(agi-panel): remove feature flag for AGI transmission and simplify button states
2026-05-09 23:55:50 +02:00

500 lines
16 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, vi, beforeEach } from 'vitest'
import { calculateSalary, calculateKarensavdrag, calculateSjuklon, calculateAvgifterRate } from '../calculation-engine'
import type { PayrollConfig } from '../payroll-config'
import type { TaxTableRate } from '../tax-tables'
// Mock personnummer module
vi.mock('../personnummer', () => ({
decryptPersonnummer: (encrypted: string) => {
// Return mock personnummer based on encrypted value
if (encrypted === 'mock_old_person') return '193501011234'
if (encrypted === 'mock_young_person') return '200301011234'
if (encrypted === 'mock_senior_person') return '195801011234'
// Generic helper: 'mock_born_YYYY' resolves to a Jan-1 birth in YYYY.
// Used by ungdomsrabatt boundary tests so each case names its own year
// explicitly rather than depending on a global mock alias.
const m = /^mock_born_(\d{4})$/.exec(encrypted)
if (m) return `${m[1]}01011234`
return '199001011234' // Default: born 1990
},
calculateAgeAtYearStart: (pnr: string, year: number) => {
const birthYear = parseInt(pnr.slice(0, 4))
return year - birthYear
},
}))
const config2026: PayrollConfig = {
configYear: 2026,
avgifterTotal: 0.3142,
avgifterAlderspension: 0.1021,
avgifterSjukforsakring: 0.0355,
avgifterForaldraforsakring: 0.0200,
avgifterEfterlevandepension: 0.0030,
avgifterArbetsmarknad: 0.0264,
avgifterArbetsskada: 0.0010,
avgifterAllmanLoneavgift: 0.1262,
avgifterReduced65plus: 0.1021,
avgifterYouthRate: 0.2081,
avgifterYouthSalaryCap: 25000,
avgifterVaxaStodRate: 0.1021,
avgifterVaxaStodCap: 35000,
avgifterMinimumAnnual: 1000,
egenavgifterTotal: 0.2897,
slpRate: 0.2426,
prisbasbelopp: 59200,
inkomstbasbelopp: 83400,
maxPgi: 625500,
sgiCeiling: 592000,
statligSkattBrytpunkt: 660400,
traktamenteHeldag: 300,
traktamenteHalvdag: 150,
traktamenteNatt: 150,
milersattningEgenBil: 25,
milersattningFormansbilFossil: 12,
milersattningFormansbilEl: 9.50,
kostformanHeldag: 310,
kostformanLunch: 124,
kostformanFrukost: 62,
friskvardCap: 5000,
bilformanSlr: 0.0255,
sjuklonRate: 0.80,
karensavdragFactor: 0.20,
maxKarensavdragPerYear: 10,
reducedAvgiftAge: 67,
}
const emptyTaxRates: TaxTableRate[] = []
function makeBasicInput(overrides = {}) {
return {
employmentType: 'employee' as const,
salaryType: 'monthly' as const,
monthlySalary: 40000,
employmentDegree: 100,
taxTableNumber: null,
taxColumn: 1,
isSidoinkomst: false,
jamkningPercentage: null,
jamkningValidFrom: null,
jamkningValidTo: null,
fSkattStatus: 'a_skatt',
personnummer: 'mock_standard',
paymentDate: '2026-04-25',
vacationRule: 'procentregeln' as const,
vacationDaysPerYear: 25,
semestertillaggRate: 0.0043,
vaxaStodEligible: false,
vaxaStodStart: null,
vaxaStodEnd: null,
lineItems: [],
...overrides,
}
}
describe('calculateSalary', () => {
it('calculates basic monthly salary correctly', () => {
const result = calculateSalary(makeBasicInput(), config2026, emptyTaxRates)
expect(result.grossSalary).toBe(40000)
// With no tax table, falls back to 30%
expect(result.taxWithheld).toBe(12000)
expect(result.netSalary).toBe(28000)
expect(result.avgifterRate).toBe(0.3142)
expect(result.avgifterAmount).toBe(Math.round(40000 * 0.3142 * 100) / 100)
expect(result.vacationAccrual).toBe(Math.round(40000 * 0.12 * 100) / 100)
expect(result.steps.length).toBeGreaterThan(0)
})
it('applies employment degree', () => {
const result = calculateSalary(
makeBasicInput({ employmentDegree: 50 }),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(20000)
expect(result.taxWithheld).toBe(6000) // 30% of 20000
expect(result.netSalary).toBe(14000)
})
it('calculates hourly salary', () => {
const result = calculateSalary(
makeBasicInput({
salaryType: 'hourly',
monthlySalary: 0,
hourlyRate: 250,
hoursWorked: 160,
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(40000) // 250 * 160
})
it('applies sidoinkomst flat 30%', () => {
const result = calculateSalary(
makeBasicInput({ isSidoinkomst: true }),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(12000) // 30% of 40000
})
it('applies f-skatt with 0% withholding', () => {
const result = calculateSalary(
makeBasicInput({ fSkattStatus: 'f_skatt' }),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(0)
expect(result.netSalary).toBe(40000)
})
it('applies unverified flat 30%', () => {
const result = calculateSalary(
makeBasicInput({ fSkattStatus: 'not_verified' }),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(12000)
})
it('applies jämkning when valid', () => {
const result = calculateSalary(
makeBasicInput({
jamkningPercentage: 15,
jamkningValidFrom: '2026-01-01',
jamkningValidTo: '2026-12-31',
}),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(6000) // 15% of 40000
})
it('does not apply jämkning when outside date range', () => {
const result = calculateSalary(
makeBasicInput({
jamkningPercentage: 15,
jamkningValidFrom: '2025-01-01',
jamkningValidTo: '2025-12-31',
paymentDate: '2026-04-25',
}),
config2026,
emptyTaxRates
)
// Should fall back to 30% since jämkning expired
expect(result.taxWithheld).toBe(12000)
})
it('handles line item additions', () => {
const result = calculateSalary(
makeBasicInput({
lineItems: [
{ itemType: 'bonus', amount: 5000, isTaxable: true, isAvgiftBasis: true, isVacationBasis: true, isGrossDeduction: false, isNetDeduction: false },
],
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(45000) // 40000 + 5000
expect(result.taxWithheld).toBe(13500) // 30% of 45000
})
it('applies gross deductions before tax', () => {
const result = calculateSalary(
makeBasicInput({
lineItems: [
{ itemType: 'gross_deduction_pension', amount: -5000, isTaxable: true, isAvgiftBasis: true, isVacationBasis: false, isGrossDeduction: true, isNetDeduction: false },
],
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(35000) // 40000 - 5000
expect(result.grossDeductions).toBe(5000)
expect(result.taxWithheld).toBe(10500) // 30% of 35000 (tax on reduced amount)
})
it('applies net deductions after tax', () => {
const result = calculateSalary(
makeBasicInput({
lineItems: [
{ itemType: 'net_deduction_advance', amount: -3000, isTaxable: false, isAvgiftBasis: false, isVacationBasis: false, isGrossDeduction: false, isNetDeduction: true },
],
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(40000) // Unaffected
expect(result.taxWithheld).toBe(12000) // 30% of 40000 (tax on full amount)
expect(result.netDeductions).toBe(3000)
expect(result.netSalary).toBe(25000) // 40000 - 12000 - 3000
})
it('calculates vacation accrual with procentregeln', () => {
const result = calculateSalary(
makeBasicInput({ vacationRule: 'procentregeln', vacationDaysPerYear: 25 }),
config2026,
emptyTaxRates
)
expect(result.vacationAccrual).toBe(Math.round(40000 * 0.12 * 100) / 100)
})
it('uses 14.4% for 30+ vacation days', () => {
const result = calculateSalary(
makeBasicInput({ vacationRule: 'procentregeln', vacationDaysPerYear: 30 }),
config2026,
emptyTaxRates
)
expect(result.vacationAccrual).toBe(Math.round(40000 * 0.144 * 100) / 100)
})
it('adds benefit values to tax base but not gross', () => {
const result = calculateSalary(
makeBasicInput({
lineItems: [
{ itemType: 'benefit_car', amount: 3000, isTaxable: true, isAvgiftBasis: true, isVacationBasis: false, isGrossDeduction: false, isNetDeduction: false },
],
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(40000) // Benefits don't add to gross
expect(result.benefitValues).toBe(3000)
expect(result.taxableIncome).toBe(43000) // gross + benefits
expect(result.taxWithheld).toBe(12900) // 30% of 43000
expect(result.netSalary).toBe(27100) // 40000 - 12900
})
it('includes employer cost calculation', () => {
const result = calculateSalary(makeBasicInput(), config2026, emptyTaxRates)
const expectedAvgifter = Math.round(40000 * 0.3142 * 100) / 100
const expectedVacation = Math.round(40000 * 0.12 * 100) / 100
const expectedVacationAvgifter = Math.round(expectedVacation * 0.3142 * 100) / 100
const expectedCost = Math.round((40000 + expectedAvgifter + expectedVacation + expectedVacationAvgifter) * 100) / 100
expect(result.totalEmployerCost).toBe(expectedCost)
})
})
describe('calculateKarensavdrag', () => {
it('calculates 20% of weekly sjuklön', () => {
// Formula: 20% × (40000 × 12/52 × 0.80)
const expected = Math.round(0.20 * (40000 * 12 / 52 * 0.80) * 100) / 100
expect(calculateKarensavdrag(40000, config2026)).toBe(expected)
})
})
describe('calculateSjuklon', () => {
it('calculates karensavdrag + sjuklön for sick days', () => {
const result = calculateSjuklon(40000, 5, config2026)
expect(result.karensavdrag).toBeGreaterThan(0)
expect(result.sjuklon).toBeGreaterThan(0)
expect(result.steps.length).toBeGreaterThan(0)
})
it('handles 1-day sick leave (karens only)', () => {
const result = calculateSjuklon(40000, 1, config2026)
expect(result.karensavdrag).toBeGreaterThan(0)
expect(result.sjuklon).toBe(0) // No sjuklön for day 1
})
it('caps at 13 sjuklön days (day 2-14)', () => {
const result14 = calculateSjuklon(40000, 14, config2026)
const result20 = calculateSjuklon(40000, 20, config2026)
// sjuklön should be the same for 14 and 20 days (capped at day 14)
expect(result14.sjuklon).toBe(result20.sjuklon)
})
})
describe('calculateAvgifterRate', () => {
it('returns standard rate for normal employee', () => {
const result = calculateAvgifterRate(
makeBasicInput(),
config2026,
2026
)
expect(result.rate).toBe(0.3142)
expect(result.category).toBe('standard')
})
it('returns reduced rate for 67+ employee', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_senior_person' }),
config2026,
2026
)
expect(result.rate).toBe(0.1021)
expect(result.category).toBe('reduced_65plus')
})
it('returns 0% for born ≤1937', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_old_person' }),
config2026,
2026
)
expect(result.rate).toBe(0)
expect(result.category).toBe('exempt')
})
it('returns växa-stöd rate when eligible', () => {
const result = calculateAvgifterRate(
makeBasicInput({
vaxaStodEligible: true,
vaxaStodStart: '2025-01-01',
vaxaStodEnd: '2026-12-31',
}),
config2026,
2026
)
expect(result.rate).toBe(0.1021)
expect(result.category).toBe('vaxa_stod')
})
// Ungdomsrabatt 2026-2027 (Prop. 2025/26:66). Eligibility test is
// age >= 18 AND age < 23 at årets ingång. Cases below pin all four age
// boundaries plus the period-window edges.
describe('youth rate (ungdomsrabatt 2026-2027)', () => {
it('NOT eligible — age 17 at year start (too young)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2009', paymentDate: '2026-05-25' }),
config2026,
2026,
)
expect(result.category).toBe('standard')
expect(result.rate).toBe(0.3142)
})
it('eligible — age 18 at year start (lower boundary)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2008', paymentDate: '2026-05-25' }),
config2026,
2026,
)
expect(result.category).toBe('youth')
expect(result.rate).toBe(0.2081)
})
it('eligible — age 22 at year start (upper boundary)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2004', paymentDate: '2026-05-25' }),
config2026,
2026,
)
expect(result.category).toBe('youth')
expect(result.rate).toBe(0.2081)
})
// Regression: this is the case Skatteverket's AGI validator rejected.
// The previous implementation incorrectly accepted age 23 at year start.
it('NOT eligible — age 23 at year start (just over)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2003', paymentDate: '2026-05-25' }),
config2026,
2026,
)
expect(result.category).toBe('standard')
expect(result.rate).toBe(0.3142)
})
it('NOT eligible — age 22 but paid March 2026 (before period starts)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2004', paymentDate: '2026-03-15' }),
config2026,
2026,
)
expect(result.category).toBe('standard')
expect(result.rate).toBe(0.3142)
})
it('NOT eligible — age 22 but paid October 2027 (after period ends)', () => {
const config2027: PayrollConfig = { ...config2026, configYear: 2027 }
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2005', paymentDate: '2027-10-10' }),
config2027,
2027,
)
expect(result.category).toBe('standard')
expect(result.rate).toBe(0.3142)
})
it('eligible — payment exactly April 1 2026 (period start edge)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2004', paymentDate: '2026-04-01' }),
config2026,
2026,
)
expect(result.category).toBe('youth')
expect(result.rate).toBe(0.2081)
})
it('eligible — payment exactly September 30 2027 (period end edge)', () => {
const config2027: PayrollConfig = { ...config2026, configYear: 2027 }
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2005', paymentDate: '2027-09-30' }),
config2027,
2027,
)
expect(result.category).toBe('youth')
expect(result.rate).toBe(0.2081)
})
})
})
describe('calculateSalary — youth cap', () => {
// The 25 000 SEK monthly cap is applied by calculateSalary (not
// calculateAvgifterRate) so it has to be exercised through the integration
// path. Salary above the cap: discounted portion at 20.81%, excess at 31.42%.
it('applies 20.81% on first 25 000 SEK and 31.42% on the excess', () => {
const result = calculateSalary(
makeBasicInput({
personnummer: 'mock_born_2004', // age 22 at year start 2026
paymentDate: '2026-06-25',
monthlySalary: 30000,
}),
config2026,
[],
)
// 25 000 × 0.2081 + 5 000 × 0.3142 = 5 202.50 + 1 571.00 = 6 773.50
expect(result.avgifterAmount).toBeCloseTo(6773.5, 1)
expect(result.avgifterCategory).toBe('youth')
})
it('applies pure 20.81% when salary is at or below the cap', () => {
const result = calculateSalary(
makeBasicInput({
personnummer: 'mock_born_2004',
paymentDate: '2026-06-25',
monthlySalary: 20000,
}),
config2026,
[],
)
// 20 000 × 0.2081 = 4 162.00
expect(result.avgifterAmount).toBeCloseTo(4162, 1)
expect(result.avgifterCategory).toBe('youth')
})
})