Salary module (#245)

* feat: implement salary module with personnummer encryption, salary entries, tax tables, and AGI tracking

- Added personnummer encryption and decryption functions for secure storage.
- Created salary entries handling for journal entries including gross salary, tax withholding, and employer contributions.
- Implemented tax table lookup functionality for calculating tax amounts based on monthly income.
- Developed SQL migration for salary module including tables for payroll configuration, tax rates, employees, salary runs, salary run employees, salary line items, and AGI declarations.
- Established row-level security policies for all new tables to ensure company-scoped access.

* feat: add salary calculation modules for 2026

- Implemented engångsskatt calculation for one-time payments with tax brackets.
- Added löneväxling functionality for salary sacrifice to pension, including employer savings and warnings.
- Created pain.001 generator for salary batch payments in compliance with Swedish banking standards.
- Developed PDF template for payslips, including detailed breakdowns and employer costs.
- Generated seed data for Swedish tax tables for 2026, including SQL insert statements.
- Implemented traktamente calculations for per diem and mileage allowances, adhering to Skatteverket regulations.
- Added seed script for populating tax tables in the database.

* feat: Update meal reduction percentages in traktamente calculation

fix: Remove obsolete seed script for 2026 tax tables

feat: Extend SalaryRunStatus type to include 'corrected' status

feat: Implement KU10 XML generation endpoint for annual employee income statements

feat: Add endpoint for creating corrections to booked salary runs

feat: Implement endpoint for sending payslip PDFs to employees

feat: Create KU10 XML generator for annual reporting

feat: Add salary transaction matcher for auto-linking bank transactions to salary entries

chore: Add database migration for salary correction support

* feat: replace select elements with custom Select component for employment and salary types

* feat: enhance salary calculations with pension entry and avgifter category support
This commit is contained in:
Mattsson
2026-04-15 11:17:39 +02:00
committed by GitHub
parent b387a77bfd
commit 04dbb31d7e
78 changed files with 10529 additions and 14 deletions
@@ -0,0 +1,187 @@
import { describe, it, expect } from 'vitest'
import { calculateSjuklon, calculateVabDeduction, calculateParentalLeaveDeduction, calculateVacationPay } from '../absence-calculator'
import type { PayrollConfig } from '../payroll-config'
const config: 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 r = (x: number) => Math.round(x * 100) / 100
describe('calculateSjuklon', () => {
it('calculates karensavdrag correctly (20% of weekly sjuklön)', () => {
const result = calculateSjuklon(30000, 1, config)
// weekly = 30000 × 12/52 × 0.80 = 5538.46
// karens = 5538.46 × 0.20 = 1107.69
const expectedWeekly = r(30000 * 12 / 52 * 0.80)
const expectedKarens = r(expectedWeekly * 0.20)
expect(result.karensavdrag).toBe(expectedKarens)
})
it('calculates sjuklön day 2-14 at 80%', () => {
const result = calculateSjuklon(30000, 5, config)
const dailyRate = r(30000 / 21)
// 4 sjuklön days (day 2-5)
const expectedSjuklon = r(dailyRate * 0.80 * 4)
expect(result.sjuklonDays).toBe(4)
expect(result.sjuklonAmount).toBe(expectedSjuklon)
})
it('caps sjuklön days at 13 (day 2-14)', () => {
const result = calculateSjuklon(30000, 20, config)
expect(result.sjuklonDays).toBe(13)
})
it('handles 1-day sickness (karens only, no sjuklön)', () => {
const result = calculateSjuklon(30000, 1, config)
expect(result.sjuklonDays).toBe(0)
expect(result.sjuklonAmount).toBe(0)
expect(result.karensavdrag).toBeGreaterThan(0)
})
it('skips karensavdrag for återinsjuknande (within 5 days)', () => {
const result = calculateSjuklon(30000, 3, config, true)
expect(result.karensavdrag).toBe(0)
// Sjuklön from day 1 since no karensavdrag
expect(result.sjuklonDays).toBe(3)
})
it('returns calculation steps for transparency', () => {
const result = calculateSjuklon(30000, 5, config)
expect(result.steps.length).toBeGreaterThanOrEqual(4)
expect(result.steps.some(s => s.label === 'Karensavdrag')).toBe(true)
expect(result.steps.some(s => s.label === 'Sjuklön dag 2-14')).toBe(true)
})
it('calculates total deduction correctly', () => {
const result = calculateSjuklon(30000, 5, config)
const dailyRate = r(30000 / 21)
const normalPay = r(dailyRate * 5)
// totalDeduction = normalPay - sjuklön + karens
expect(result.totalDeduction).toBe(r(normalPay - result.sjuklonAmount + result.karensavdrag))
})
})
describe('calculateVabDeduction', () => {
it('calculates daily rate deduction', () => {
const result = calculateVabDeduction(30000, 3)
const expectedDeduction = r(30000 / 21 * 3)
expect(result.deduction).toBe(expectedDeduction)
})
it('marks as semesterlönegrundande within 120 days', () => {
const result = calculateVabDeduction(30000, 5, 100)
expect(result.semesterGrundande).toBe(true) // 100 + 5 = 105 ≤ 120
})
it('marks as not semesterlönegrundande after 120 days', () => {
const result = calculateVabDeduction(30000, 5, 118)
expect(result.semesterGrundande).toBe(false) // 118 + 5 = 123 > 120
})
})
describe('calculateParentalLeaveDeduction', () => {
it('calculates daily rate deduction', () => {
const result = calculateParentalLeaveDeduction(30000, 10)
// Daily rate is rounded first, then multiplied: r(r(30000/21) * 10)
const dailyRate = r(30000 / 21)
const expectedDeduction = r(dailyRate * 10)
expect(result.deduction).toBe(expectedDeduction)
})
it('marks as semesterlönegrundande within 120 days per pregnancy', () => {
const result = calculateParentalLeaveDeduction(30000, 10, 100)
expect(result.semesterGrundande).toBe(true) // 100 + 10 = 110 ≤ 120
})
})
describe('calculateVacationPay', () => {
it('calculates sammalöneregeln tillägg', () => {
const result = calculateVacationPay({
monthlySalary: 40000,
vacationDaysTaken: 5,
vacationRule: 'sammaloneregeln',
semestertillaggRate: 0.0043,
vacationDaysPerYear: 25,
})
const expectedTillagg = r(40000 * 0.0043 * 5)
expect(result.tillagg).toBe(expectedTillagg)
expect(result.amount).toBe(expectedTillagg)
})
it('uses 0.8% CBA rate when specified', () => {
const result = calculateVacationPay({
monthlySalary: 40000,
vacationDaysTaken: 5,
vacationRule: 'sammaloneregeln',
semestertillaggRate: 0.008,
vacationDaysPerYear: 25,
})
const expectedTillagg = r(40000 * 0.008 * 5)
expect(result.tillagg).toBe(expectedTillagg)
})
it('calculates procentregeln at 12%', () => {
const result = calculateVacationPay({
monthlySalary: 30000,
vacationDaysTaken: 5,
vacationRule: 'procentregeln',
semestertillaggRate: 0.0043,
vacationDaysPerYear: 25,
})
const annualBasis = r(30000 * 12)
const totalVacPay = r(annualBasis * 0.12)
const perDay = r(totalVacPay / 25)
expect(result.amount).toBe(r(perDay * 5))
})
it('uses 14.4% for 30+ vacation days', () => {
const result = calculateVacationPay({
monthlySalary: 30000,
vacationDaysTaken: 5,
vacationRule: 'procentregeln',
semestertillaggRate: 0.0043,
vacationDaysPerYear: 30,
})
const annualBasis = r(30000 * 12)
const totalVacPay = r(annualBasis * 0.144)
const perDay = r(totalVacPay / 30)
expect(result.amount).toBe(r(perDay * 5))
})
})
+176
View File
@@ -0,0 +1,176 @@
import { describe, it, expect, vi } from 'vitest'
import { generateAGIXml, buildIndividuppgifterSnapshot } from '../agi/xml-generator'
import type { AGICompanyData, AGIEmployeeData, AGITotals } from '../agi/xml-generator'
// Mock personnummer decryption
vi.mock('../personnummer', () => ({
decryptPersonnummer: (encrypted: string) => {
if (encrypted === 'emp1_encrypted') return '199001011234'
if (encrypted === 'emp2_encrypted') return '198506159876'
return '000000000000'
},
}))
const company: AGICompanyData = {
orgNumber: '556123-4567',
companyName: 'Test AB',
periodYear: 2026,
periodMonth: 4,
contactName: 'Anna Admin',
contactPhone: '0701234567',
contactEmail: 'anna@test.se',
}
const employees: AGIEmployeeData[] = [
{
personnummer: 'emp1_encrypted',
specificationNumber: 1,
grossSalary: 40000,
taxWithheld: 12000,
avgifterBasis: 40000,
sickDays: 3,
vabDays: 2,
},
{
personnummer: 'emp2_encrypted',
specificationNumber: 2,
grossSalary: 35000,
taxWithheld: 10500,
avgifterBasis: 35000,
benefitCar: 5000,
},
]
const totals: AGITotals = {
totalTax: 22500,
totalAvgifterBasis: 80000,
avgifterByCategory: {
standard: { basis: 75000, amount: 23565 },
reduced65plus: { basis: 5000, amount: 510.50 },
},
}
describe('generateAGIXml', () => {
it('generates valid XML with correct root element', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>')
expect(xml).toContain('<Skatteverket')
expect(xml).toContain('</Skatteverket>')
})
it('includes program name "gnubok"', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Programnamn>gnubok</Programnamn>')
})
it('includes correct period', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Period>202604</Period>')
})
it('includes org number without dash', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('5561234567')
expect(xml).not.toContain('556123-4567')
})
it('includes huvuduppgift with total tax (Ruta 001)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<AvdragenSkatt faltkod="001">22500</AvdragenSkatt>')
})
it('includes avgifter categories (Ruta 060, 061)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('faltkod="060"')
expect(xml).toContain('faltkod="061"')
})
it('decrypts personnummer for FK215', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Personnummer faltkod="215">199001011234</Personnummer>')
expect(xml).toContain('<Personnummer faltkod="215">198506159876</Personnummer>')
})
it('includes consistent FK570 specifikationsnummer', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Specifikationsnummer faltkod="570">1</Specifikationsnummer>')
expect(xml).toContain('<Specifikationsnummer faltkod="570">2</Specifikationsnummer>')
})
it('includes gross salary (Ruta 011) per employee', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<KontantBruttoloen faltkod="011">40000</KontantBruttoloen>')
expect(xml).toContain('<KontantBruttoloen faltkod="011">35000</KontantBruttoloen>')
})
it('includes tax withheld (Ruta 001) per employee', () => {
const xml = generateAGIXml(company, employees, totals)
// Both HU and IU have AvdragenSkatt
const matches = xml.match(/AvdragenSkatt/g)
expect(matches!.length).toBeGreaterThanOrEqual(3) // 1 HU + 2 IU
})
it('includes benefit values (Ruta 012 for car)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<FormanBil faltkod="012">5000</FormanBil>')
})
it('includes absence fields FK821-FK823', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<SjukfranvaroDagar faltkod="821">3</SjukfranvaroDagar>')
expect(xml).toContain('<VabDagar faltkod="822">2</VabDagar>')
})
it('omits zero/undefined fields', () => {
const xml = generateAGIXml(company, employees, totals)
// Employee 1 has no car benefit
// Employee 2 has no sick days
// Check that we don't emit empty tags
const lines = xml.split('\n')
for (const line of lines) {
if (line.includes('faltkod')) {
expect(line).not.toContain('>0</')
}
}
})
it('marks corrections with Rattelse flag', () => {
const xml = generateAGIXml(company, employees, totals, true)
expect(xml).toContain('<Rattelse>J</Rattelse>')
})
it('does not include Rattelse flag for initial filing', () => {
const xml = generateAGIXml(company, employees, totals, false)
expect(xml).not.toContain('Rattelse')
})
it('escapes XML special characters in company name', () => {
const specialCompany = { ...company, companyName: 'Test & <Co>' }
const xml = generateAGIXml(specialCompany, employees, totals)
expect(xml).not.toContain('Test & <Co>')
})
})
describe('buildIndividuppgifterSnapshot', () => {
it('builds snapshot with decrypted personnummer', () => {
const snapshot = buildIndividuppgifterSnapshot(employees)
expect(snapshot).toHaveLength(2)
expect(snapshot[0].personnummer).toBe('199001011234')
expect(snapshot[1].personnummer).toBe('198506159876')
})
it('preserves FK570 for correction reference', () => {
const snapshot = buildIndividuppgifterSnapshot(employees)
expect(snapshot[0].fk570).toBe(1)
expect(snapshot[1].fk570).toBe(2)
})
it('includes all required rutor', () => {
const snapshot = buildIndividuppgifterSnapshot(employees)
expect(snapshot[0]).toHaveProperty('ruta011', 40000)
expect(snapshot[0]).toHaveProperty('ruta001', 12000)
expect(snapshot[0]).toHaveProperty('ruta020', 40000)
expect(snapshot[0]).toHaveProperty('fk821', 3)
expect(snapshot[0]).toHaveProperty('fk822', 2)
})
})
+153
View File
@@ -0,0 +1,153 @@
import { describe, it, expect } from 'vitest'
import { calculateCarBenefit, getMealBenefitValue, calculateWellnessBenefit } from '../benefits'
import type { PayrollConfig } from '../payroll-config'
const config: 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,
}
describe('calculateCarBenefit', () => {
it('calculates Gen3 car benefit', () => {
const result = calculateCarBenefit({
nybilspris: 350000,
fordonsskatt: 3600,
isEnvironmental: false,
highMileage: false,
}, config)
// 0.29 × 59200 = 17168
// 350000 × (0.70 × 0.0255 + 0.01) = 350000 × 0.02785 = 9747.5
// 0.13 × 350000 = 45500
// + 3600 fordonsskatt
// = 17168 + 9747.5 + 45500 + 3600 = 76015.5
// monthly = 76015.5 / 12 ≈ 6334.63
expect(result.annualValue).toBeGreaterThan(0)
expect(result.monthlyValue).toBe(Math.round(result.annualValue / 12 * 100) / 100)
expect(result.steps.length).toBeGreaterThanOrEqual(2)
})
it('applies environmental reduction for electric cars', () => {
const standard = calculateCarBenefit({
nybilspris: 500000,
fordonsskatt: 3600,
isEnvironmental: false,
highMileage: false,
}, config)
const electric = calculateCarBenefit({
nybilspris: 500000,
fordonsskatt: 360,
isEnvironmental: true,
environmentalType: 'electric',
highMileage: false,
}, config)
// Electric reduces nybilspris by 350,000 (max 50% of 500,000 = 250,000)
// So reduction is capped at 250,000
expect(electric.annualValue).toBeLessThan(standard.annualValue)
})
it('caps environmental reduction at 50% of nybilspris', () => {
const result = calculateCarBenefit({
nybilspris: 400000,
fordonsskatt: 3600,
isEnvironmental: true,
environmentalType: 'electric', // Would reduce by 350,000 but cap at 200,000
highMileage: false,
}, config)
// Reduction: min(350000, 400000 × 0.5) = 200000
// Adjusted price: 200000
expect(result.steps.some(s => s.label.includes('Miljöbils'))).toBe(true)
})
it('applies 25% high-mileage reduction', () => {
const normal = calculateCarBenefit({
nybilspris: 350000,
fordonsskatt: 3600,
isEnvironmental: false,
highMileage: false,
}, config)
const highMileage = calculateCarBenefit({
nybilspris: 350000,
fordonsskatt: 3600,
isEnvironmental: false,
highMileage: true,
}, config)
expect(highMileage.annualValue).toBe(Math.round(normal.annualValue * 0.75 * 100) / 100)
})
})
describe('getMealBenefitValue', () => {
it('returns correct schablonvärde for 2026', () => {
expect(getMealBenefitValue('full_day', config)).toBe(310)
expect(getMealBenefitValue('lunch', config)).toBe(124)
expect(getMealBenefitValue('breakfast', config)).toBe(62)
})
})
describe('calculateWellnessBenefit', () => {
it('marks as tax-free when within cap', () => {
const result = calculateWellnessBenefit(3000, 0, config)
expect(result.taxable).toBe(false)
expect(result.taxableAmount).toBe(0)
})
it('marks ENTIRE amount as taxable when cap exceeded', () => {
const result = calculateWellnessBenefit(3000, 3000, config)
// YTD = 3000 + 3000 = 6000 > 5000 cap
expect(result.taxable).toBe(true)
expect(result.taxableAmount).toBe(6000) // Full amount, not just excess
})
it('handles exact cap boundary', () => {
const result = calculateWellnessBenefit(2500, 2500, config)
// YTD = 5000 = cap — still tax-free
expect(result.taxable).toBe(false)
})
it('handles single amount exceeding cap', () => {
const result = calculateWellnessBenefit(6000, 0, config)
expect(result.taxable).toBe(true)
expect(result.taxableAmount).toBe(6000)
})
})
@@ -0,0 +1,370 @@
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'
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')
})
})
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { calculateEngangsskatt } from '../engangsskatt'
describe('calculateEngangsskatt', () => {
it('calculates 0% tax for very low annual income', () => {
const result = calculateEngangsskatt(5000, 1000)
// Annual: 1000 × 12 + 5000 = 17,000 → bracket 0-20,000 → 0%
expect(result.taxRate).toBe(0.00)
expect(result.taxAmount).toBe(0)
})
it('calculates tax for moderate annual income', () => {
const result = calculateEngangsskatt(10000, 25000)
// Annual: 25000 × 12 + 10000 = 310,000 → bracket 200,001-350,000 → 30%
expect(result.taxRate).toBe(0.30)
expect(result.taxAmount).toBe(3000)
})
it('calculates higher tax when annual income crosses state tax threshold', () => {
const result = calculateEngangsskatt(50000, 55000)
// Annual: 55000 × 12 + 50000 = 710,000 → bracket 660,401-950,000 → 52%
expect(result.taxRate).toBe(0.52)
expect(result.taxAmount).toBe(26000)
})
it('uses total annual income including bonus for bracket lookup', () => {
const result = calculateEngangsskatt(100000, 40000)
// Annual: 40000 × 12 + 100000 = 580,000 → bracket 500,001-660,400 → 34%
expect(result.taxRate).toBe(0.34)
expect(result.taxAmount).toBe(34000)
})
it('returns calculation steps for transparency', () => {
const result = calculateEngangsskatt(10000, 30000)
expect(result.steps.length).toBe(2)
expect(result.steps[0].label).toBe('Beräknad årsinkomst')
expect(result.annualIncomeEstimate).toBe(370000) // 30000 × 12 + 10000
})
it('handles very high income bracket', () => {
const result = calculateEngangsskatt(200000, 120000)
// Annual: 120000 × 12 + 200000 = 1,640,000 → bracket ≥1,500,001 → 57%
expect(result.taxRate).toBe(0.57)
expect(result.taxAmount).toBe(114000)
})
})
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest'
import { calculateLoneVaxling } from '../lonevaxling'
import type { PayrollConfig } from '../payroll-config'
const config: 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 r = (x: number) => Math.round(x * 100) / 100
describe('calculateLoneVaxling', () => {
it('applies 1.058 factor to pension contribution', () => {
const result = calculateLoneVaxling(5000, 60000, config)
expect(result.pensionContribution).toBe(r(5000 * 1.058))
})
it('reduces salary by exact reduction amount', () => {
const result = calculateLoneVaxling(5000, 60000, config)
expect(result.postReductionSalary).toBe(55000)
})
it('calculates saved avgifter', () => {
const result = calculateLoneVaxling(5000, 60000, config)
expect(result.savedAvgifter).toBe(r(5000 * 0.3142))
})
it('calculates SLP on pension at 24.26%', () => {
const result = calculateLoneVaxling(5000, 60000, config)
const expectedSlp = r(5000 * 1.058 * 0.2426)
expect(result.slpOnPension).toBe(expectedSlp)
})
it('warns when post-reduction salary drops below PGI floor', () => {
// PGI floor = 8.07 × 83400 / 12 ≈ 56,088.50
const result = calculateLoneVaxling(20000, 60000, config)
// Post-reduction: 40,000 < 56,088
expect(result.warnings.length).toBeGreaterThan(0)
expect(result.warnings[0]).toContain('PGI-golv')
})
it('does not warn when salary stays above PGI floor', () => {
const result = calculateLoneVaxling(3000, 80000, config)
// Post-reduction: 77,000 > PGI floor
const pgiWarnings = result.warnings.filter(w => w.includes('PGI'))
expect(pgiWarnings.length).toBe(0)
})
it('warns when annual pension exceeds 10 × PBB cap', () => {
// Cap = 10 × 59200 = 592,000 SEK/year
// Monthly contribution = 50000 × 1.058 = 52,900 → annual = 634,800 > 592,000
const result = calculateLoneVaxling(50000, 100000, config)
expect(result.warnings.some(w => w.includes('PBB'))).toBe(true)
})
it('returns calculation steps for transparency', () => {
const result = calculateLoneVaxling(5000, 60000, config)
expect(result.steps.length).toBeGreaterThanOrEqual(5)
expect(result.steps.some(s => s.label === 'Pensionsavsättning')).toBe(true)
expect(result.steps.some(s => s.label === 'Särskild löneskatt på pension')).toBe(true)
})
})
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest'
import { generatePain001 } from '../payment/pain001-generator'
describe('generatePain001', () => {
const company = {
name: 'Test AB',
orgNumber: '556123-4567',
iban: 'SE1234567890123456789012',
bic: 'ESSESESS',
}
const employees = [
{ name: 'Anna Andersson', clearingNumber: '5678', bankAccountNumber: '1234567890', netSalary: 28000 },
{ name: 'Erik Eriksson', clearingNumber: '1234', bankAccountNumber: '9876543210', netSalary: 32000 },
]
const options = {
messageId: 'GNUBOK-5561234567-2026-04',
paymentDate: '2026-04-25',
periodLabel: '2026-04',
}
it('generates valid XML structure', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>')
expect(xml).toContain('pain.001.001.03')
expect(xml).toContain('<CstmrCdtTrfInitn>')
expect(xml).toContain('</Document>')
})
it('includes group header with correct counts', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain(`<NbOfTxs>2</NbOfTxs>`)
expect(xml).toContain(`<CtrlSum>60000.00</CtrlSum>`)
})
it('includes company details', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('<Nm>Test AB</Nm>')
expect(xml).toContain('SE1234567890123456789012')
expect(xml).toContain('ESSESESS')
})
it('includes SALA category purpose for salary', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('<Cd>SALA</Cd>')
})
it('includes per-employee credit transfers', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('<Nm>Anna Andersson</Nm>')
expect(xml).toContain('<InstdAmt Ccy="SEK">28000.00</InstdAmt>')
expect(xml).toContain('<Nm>Erik Eriksson</Nm>')
expect(xml).toContain('<InstdAmt Ccy="SEK">32000.00</InstdAmt>')
})
it('includes bank account details per employee', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('56781234567890') // Anna's clearing + account
expect(xml).toContain('12349876543210') // Erik's clearing + account
})
it('includes remittance info with period label', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('Lon 2026-04')
})
it('includes payment date', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('<ReqdExctnDt>2026-04-25</ReqdExctnDt>')
})
it('escapes XML special characters', () => {
const specialCompany = { ...company, name: 'Test & Sons <AB>' }
const xml = generatePain001(specialCompany, employees, options)
expect(xml).toContain('Test &amp; Sons &lt;AB&gt;')
expect(xml).not.toContain('Test & Sons <AB>')
})
it('formats amounts with 2 decimal places', () => {
const empWithDecimals = [
{ name: 'Test', clearingNumber: '1234', bankAccountNumber: '5678', netSalary: 28333.33 },
]
const xml = generatePain001(company, empWithDecimals, options)
expect(xml).toContain('28333.33')
})
})
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest'
import {
validatePersonnummer,
extractLast4,
extractBirthDate,
calculateAge,
calculateAgeAtYearStart,
maskPersonnummer,
formatPersonnummer,
encryptPersonnummer,
decryptPersonnummer,
} from '../personnummer'
describe('validatePersonnummer', () => {
it('accepts valid 12-digit personnummer', () => {
// Valid test personnummer (checksum matches)
const result = validatePersonnummer('199001019802')
expect(result.valid).toBe(true)
})
it('rejects non-12-digit input', () => {
const result = validatePersonnummer('9001019802')
expect(result.valid).toBe(false)
expect(result.error).toContain('12 siffror')
})
it('rejects invalid month', () => {
const result = validatePersonnummer('199013019802')
expect(result.valid).toBe(false)
expect(result.error).toContain('månad')
})
it('rejects invalid day', () => {
const result = validatePersonnummer('199001329802')
expect(result.valid).toBe(false)
expect(result.error).toContain('dag')
})
it('strips non-digits before validation', () => {
const result = validatePersonnummer('19900101-9802')
expect(result.valid).toBe(true)
})
})
describe('extractLast4', () => {
it('extracts last 4 digits', () => {
expect(extractLast4('199001019802')).toBe('9802')
})
it('handles dash-formatted input', () => {
expect(extractLast4('19900101-9802')).toBe('9802')
})
})
describe('extractBirthDate', () => {
it('extracts birth date from 12-digit personnummer', () => {
const result = extractBirthDate('199001019802')
expect(result.year).toBe(1990)
expect(result.month).toBe(1)
expect(result.day).toBe(1)
})
})
describe('calculateAge', () => {
it('calculates age at a given date', () => {
expect(calculateAge('199001019802', '2026-04-14')).toBe(36)
})
it('returns age minus one before birthday', () => {
expect(calculateAge('199006159802', '2026-06-14')).toBe(35)
expect(calculateAge('199006159802', '2026-06-15')).toBe(36)
})
})
describe('calculateAgeAtYearStart', () => {
it('calculates age at January 1 of given year', () => {
expect(calculateAgeAtYearStart('199001019802', 2026)).toBe(36)
expect(calculateAgeAtYearStart('199012319802', 2026)).toBe(35)
})
})
describe('maskPersonnummer', () => {
it('masks with XXXXXXXX-XXXX format', () => {
expect(maskPersonnummer('9802')).toBe('XXXXXXXX-9802')
})
})
describe('formatPersonnummer', () => {
it('formats with dash', () => {
expect(formatPersonnummer('199001019802')).toBe('19900101-9802')
})
})
describe('encryption roundtrip', () => {
it('encrypts and decrypts correctly', () => {
const pnr = '199001019802'
const encrypted = encryptPersonnummer(pnr)
expect(encrypted).not.toBe(pnr)
expect(encrypted.length).toBeGreaterThan(pnr.length)
const decrypted = decryptPersonnummer(encrypted)
expect(decrypted).toBe(pnr)
})
it('produces different ciphertexts for same input (random IV)', () => {
const pnr = '199001019802'
const a = encryptPersonnummer(pnr)
const b = encryptPersonnummer(pnr)
expect(a).not.toBe(b)
})
})
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { lookupTaxAmount, calculateJamkningTax, calculateSidoinkomstTax } from '../tax-tables'
import type { TaxTableRate } from '../tax-tables'
const sampleRates: TaxTableRate[] = [
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 0, incomeTo: 2200, taxAmount: 0 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 2201, incomeTo: 20000, taxAmount: 2800 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 20001, incomeTo: 30000, taxAmount: 5600 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 30001, incomeTo: 40000, taxAmount: 8900 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 40001, incomeTo: 50000, taxAmount: 12500 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 50001, incomeTo: 60000, taxAmount: 16800 },
]
describe('lookupTaxAmount', () => {
it('returns 0 for income below minimum bracket', () => {
expect(lookupTaxAmount(33, 1, 1500, sampleRates)).toBe(0)
})
it('matches correct bracket for mid-range income', () => {
expect(lookupTaxAmount(33, 1, 25000, sampleRates)).toBe(5600)
})
it('matches bracket boundary exactly', () => {
expect(lookupTaxAmount(33, 1, 20001, sampleRates)).toBe(5600)
expect(lookupTaxAmount(33, 1, 30000, sampleRates)).toBe(5600)
})
it('uses last bracket for income exceeding all brackets', () => {
expect(lookupTaxAmount(33, 1, 100000, sampleRates)).toBe(16800)
})
it('falls back to 30% when table not found', () => {
expect(lookupTaxAmount(99, 1, 40000, sampleRates)).toBe(12000)
})
it('filters by correct column', () => {
const rates: TaxTableRate[] = [
...sampleRates,
{ tableYear: 2026, tableNumber: 33, columnNumber: 2, incomeFrom: 0, incomeTo: 50000, taxAmount: 999 },
]
expect(lookupTaxAmount(33, 2, 30000, rates)).toBe(999)
})
})
describe('calculateJamkningTax', () => {
it('calculates tax using custom percentage', () => {
expect(calculateJamkningTax(40000, 20)).toBe(8000)
})
it('rounds to 2 decimal places', () => {
expect(calculateJamkningTax(33333, 15.5)).toBe(5166.62)
})
})
describe('calculateSidoinkomstTax', () => {
it('calculates flat 30%', () => {
expect(calculateSidoinkomstTax(40000)).toBe(12000)
})
it('rounds to 2 decimal places', () => {
expect(calculateSidoinkomstTax(33333)).toBe(9999.90)
})
})
+163
View File
@@ -0,0 +1,163 @@
import { describe, it, expect } from 'vitest'
import { calculateTraktamente, calculateMileageAllowance } from '../traktamente'
import type { PayrollConfig } from '../payroll-config'
const config: 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,
}
describe('calculateTraktamente', () => {
it('calculates full-day traktamente at 300 SEK', () => {
const result = calculateTraktamente({
tripType: 'full_day',
days: 3,
mealsProvided: 'none',
consecutiveMonths: 0,
config,
})
expect(result.taxFree).toBe(900) // 300 × 3
expect(result.taxable).toBe(0)
})
it('calculates half-day at 150 SEK', () => {
const result = calculateTraktamente({
tripType: 'half_day',
days: 2,
mealsProvided: 'none',
consecutiveMonths: 0,
config,
})
expect(result.taxFree).toBe(300) // 150 × 2
})
it('applies tremånadersregeln — 70% after 3 months', () => {
const result = calculateTraktamente({
tripType: 'full_day',
days: 1,
mealsProvided: 'none',
consecutiveMonths: 4,
config,
})
expect(result.taxFree).toBe(Math.round(300 * 0.70 * 100) / 100) // 210
// If employer pays full rate (300), excess is taxable
expect(result.taxable).toBe(Math.round((300 - 210) * 100) / 100) // 90
})
it('applies tremånadersregeln — 50% after 2 years', () => {
const result = calculateTraktamente({
tripType: 'full_day',
days: 1,
mealsProvided: 'none',
consecutiveMonths: 25,
config,
})
expect(result.taxFree).toBe(150) // 300 × 50%
expect(result.taxable).toBe(150) // 300 - 150
})
it('reduces for meals provided — lunch reduces by 35%', () => {
const result = calculateTraktamente({
tripType: 'full_day',
days: 1,
mealsProvided: 'lunch',
consecutiveMonths: 0,
config,
})
const mealReduction = Math.round(300 * 0.35 * 100) / 100
expect(result.taxFree).toBe(Math.round((300 - mealReduction) * 100) / 100)
})
it('reduces for all meals — 85%', () => {
const result = calculateTraktamente({
tripType: 'full_day',
days: 1,
mealsProvided: 'all',
consecutiveMonths: 0,
config,
})
const mealReduction = Math.round(300 * 0.85 * 100) / 100
expect(result.taxFree).toBe(Math.round((300 - mealReduction) * 100) / 100)
})
})
describe('calculateMileageAllowance', () => {
it('calculates own car at 25 SEK/mil', () => {
const result = calculateMileageAllowance({
mil: 10,
vehicleType: 'own_car',
paidPerMil: 25,
config,
})
expect(result.taxFree).toBe(250) // 25 × 10
expect(result.taxable).toBe(0)
})
it('calculates company car fossil at 12 SEK/mil', () => {
const result = calculateMileageAllowance({
mil: 10,
vehicleType: 'company_car_fossil',
paidPerMil: 12,
config,
})
expect(result.taxFree).toBe(120)
expect(result.taxable).toBe(0)
})
it('calculates company car electric at 9.50 SEK/mil', () => {
const result = calculateMileageAllowance({
mil: 10,
vehicleType: 'company_car_electric',
paidPerMil: 9.50,
config,
})
expect(result.taxFree).toBe(95)
expect(result.taxable).toBe(0)
})
it('marks excess as taxable when paid above tax-free rate', () => {
const result = calculateMileageAllowance({
mil: 10,
vehicleType: 'own_car',
paidPerMil: 30, // 5 above tax-free
config,
})
expect(result.taxFree).toBe(250) // 25 × 10
expect(result.taxable).toBe(50) // (30-25) × 10
})
})