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:
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 & Sons <AB>')
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user