* 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
188 lines
6.3 KiB
TypeScript
188 lines
6.3 KiB
TypeScript
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))
|
||
})
|
||
})
|