* 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
154 lines
4.7 KiB
TypeScript
154 lines
4.7 KiB
TypeScript
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)
|
||
})
|
||
})
|