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
+594
View File
@@ -0,0 +1,594 @@
import type { PayrollConfig } from './payroll-config'
import type { TaxTableRate } from './tax-tables'
import { lookupTaxAmount, calculateJamkningTax, calculateSidoinkomstTax } from './tax-tables'
import { calculateAgeAtYearStart, decryptPersonnummer } from './personnummer'
import type { SalaryLineItemType } from '@/types'
// ============================================================
// Types
// ============================================================
export interface SalaryCalculationInput {
/** Employee data */
employmentType: 'employee' | 'company_owner' | 'board_member'
salaryType: 'monthly' | 'hourly'
monthlySalary: number
hourlyRate?: number
hoursWorked?: number
employmentDegree: number // 1-100
/** Tax */
taxTableNumber: number | null
taxColumn: number
isSidoinkomst: boolean
jamkningPercentage: number | null
jamkningValidFrom: string | null
jamkningValidTo: string | null
fSkattStatus: string
/** Age (from personnummer) */
personnummer: string // encrypted — will be decrypted for age calc
paymentDate: string
/** Vacation */
vacationRule: 'procentregeln' | 'sammaloneregeln'
vacationDaysPerYear: number
semestertillaggRate: number
/** Växa-stöd */
vaxaStodEligible: boolean
vaxaStodStart: string | null
vaxaStodEnd: string | null
/** Line items */
lineItems: CalculationLineItem[]
}
export interface CalculationLineItem {
itemType: SalaryLineItemType
amount: number
isTaxable: boolean
isAvgiftBasis: boolean
isVacationBasis: boolean
isGrossDeduction: boolean
isNetDeduction: boolean
}
export interface CalculationStep {
label: string
formula: string
input: Record<string, number | string>
output: number
}
export interface SalaryCalculationResult {
grossSalary: number
grossDeductions: number
benefitValues: number
taxableIncome: number
taxWithheld: number
netDeductions: number
netSalary: number
avgifterRate: number
avgifterAmount: number
avgifterBasis: number
avgifterCategory: AvgifterCalculation['category']
vacationAccrual: number
vacationAccrualAvgifter: number
totalEmployerCost: number
steps: CalculationStep[]
}
export interface AvgifterCalculation {
rate: number
amount: number
basis: number
category: 'standard' | 'reduced_65plus' | 'youth' | 'vaxa_stod' | 'exempt'
steps: CalculationStep[]
}
// ============================================================
// Rounding helper
// ============================================================
function r(x: number): number {
return Math.round(x * 100) / 100
}
// ============================================================
// Main calculation
// ============================================================
/**
* Calculate salary for one employee in a salary run.
* Follows the legally mandated processing order:
* 1. Base salary
* 2. Add additions (overtime, bonus, etc.)
* 3. Subtract absence deductions
* 4. Apply bruttolöneavdrag (MUST be before tax)
* 5. Add förmånsvärden to tax base
* 6. Tax withholding
* 7. Net salary
* 8. Employer contributions (avgifter)
* 9. Vacation accrual
* 10. Avgifter on vacation accrual
*/
export function calculateSalary(
input: SalaryCalculationInput,
config: PayrollConfig,
taxRates: TaxTableRate[]
): SalaryCalculationResult {
const steps: CalculationStep[] = []
// ─── Step 1: Base salary ───
let baseSalary: number
if (input.salaryType === 'monthly') {
baseSalary = r(input.monthlySalary * (input.employmentDegree / 100))
steps.push({
label: 'Grundlön',
formula: 'monthly_salary × (employment_degree / 100)',
input: { monthly_salary: input.monthlySalary, employment_degree: input.employmentDegree },
output: baseSalary,
})
} else {
const hours = input.hoursWorked || 0
const rate = input.hourlyRate || 0
baseSalary = r(rate * hours)
steps.push({
label: 'Grundlön (timavlönad)',
formula: 'hourly_rate × hours_worked',
input: { hourly_rate: rate, hours_worked: hours },
output: baseSalary,
})
}
// ─── Step 2: Add additions ───
const additions = input.lineItems.filter(
li => ['overtime', 'bonus', 'commission'].includes(li.itemType) && li.amount > 0
)
const totalAdditions = r(additions.reduce((sum, li) => sum + li.amount, 0))
if (totalAdditions > 0) {
steps.push({
label: 'Tillägg (övertid, bonus, provision)',
formula: 'sum(additions)',
input: { count: additions.length },
output: totalAdditions,
})
}
// ─── Step 3: Subtract absence deductions ───
const absenceItems = input.lineItems.filter(
li => ['sick_karens', 'sick_day2_14', 'sick_day15_plus', 'vab', 'parental_leave', 'vacation'].includes(li.itemType)
)
const totalAbsence = r(absenceItems.reduce((sum, li) => sum + li.amount, 0))
if (totalAbsence !== 0) {
steps.push({
label: 'Frånvaro (sjuk, VAB, semester, föräldraledig)',
formula: 'sum(absence_items)',
input: { count: absenceItems.length },
output: totalAbsence,
})
}
// ─── Step 4: Bruttolöneavdrag (MUST be before tax) ───
const grossDeductionItems = input.lineItems.filter(li => li.isGrossDeduction)
const totalGrossDeductions = r(Math.abs(grossDeductionItems.reduce((sum, li) => sum + li.amount, 0)))
if (totalGrossDeductions > 0) {
steps.push({
label: 'Bruttolöneavdrag',
formula: 'sum(gross_deductions)',
input: { count: grossDeductionItems.length },
output: -totalGrossDeductions,
})
}
// Gross salary = base + additions + absence (may be negative for deductions) - gross deductions
const grossSalary = r(baseSalary + totalAdditions + totalAbsence - totalGrossDeductions)
steps.push({
label: 'Bruttolön',
formula: 'base + additions + absence - gross_deductions',
input: { base: baseSalary, additions: totalAdditions, absence: totalAbsence, gross_deductions: totalGrossDeductions },
output: grossSalary,
})
// ─── Step 5: Add förmånsvärden to tax base ───
const benefitItems = input.lineItems.filter(
li => ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_other'].includes(li.itemType)
)
const totalBenefits = r(benefitItems.reduce((sum, li) => sum + li.amount, 0))
if (totalBenefits > 0) {
steps.push({
label: 'Förmånsvärden',
formula: 'sum(benefit_values)',
input: { count: benefitItems.length },
output: totalBenefits,
})
}
const taxableIncome = r(grossSalary + totalBenefits)
steps.push({
label: 'Skattegrundande inkomst',
formula: 'gross_salary + benefit_values',
input: { gross_salary: grossSalary, benefit_values: totalBenefits },
output: taxableIncome,
})
// ─── Step 6: Tax withholding ───
let taxWithheld: number
const paymentYear = parseInt(input.paymentDate.split('-')[0])
if (input.fSkattStatus === 'f_skatt') {
// F-skatt holder: no withholding
taxWithheld = 0
steps.push({
label: 'Skatteavdrag (F-skatt)',
formula: '0 (F-skattsedel, inget avdrag)',
input: {},
output: 0,
})
} else if (input.fSkattStatus === 'not_verified') {
// Unverified: flat 30%
taxWithheld = r(taxableIncome * 0.30)
steps.push({
label: 'Skatteavdrag (ej verifierad)',
formula: 'taxable_income × 30%',
input: { taxable_income: taxableIncome },
output: taxWithheld,
})
} else if (input.isSidoinkomst) {
// Sidoinkomst: flat 30%
taxWithheld = calculateSidoinkomstTax(taxableIncome)
steps.push({
label: 'Skatteavdrag (sidoinkomst 30%)',
formula: 'taxable_income × 30%',
input: { taxable_income: taxableIncome },
output: taxWithheld,
})
} else if (input.jamkningPercentage !== null && isJamkningValid(input.jamkningValidFrom, input.jamkningValidTo, input.paymentDate)) {
// Jämkning
taxWithheld = calculateJamkningTax(taxableIncome, input.jamkningPercentage)
steps.push({
label: `Skatteavdrag (jämkning ${input.jamkningPercentage}%)`,
formula: 'taxable_income × jamkning_percentage / 100',
input: { taxable_income: taxableIncome, jamkning_percentage: input.jamkningPercentage },
output: taxWithheld,
})
} else if (input.taxTableNumber) {
// Normal tax table lookup
taxWithheld = lookupTaxAmount(input.taxTableNumber, input.taxColumn, taxableIncome, taxRates)
steps.push({
label: `Skatteavdrag (tabell ${input.taxTableNumber}, kolumn ${input.taxColumn})`,
formula: `lookup(table=${input.taxTableNumber}, column=${input.taxColumn}, income=${Math.round(taxableIncome)})`,
input: { table: input.taxTableNumber, column: input.taxColumn, taxable_income: taxableIncome },
output: taxWithheld,
})
} else {
// Fallback: flat 30%
taxWithheld = r(taxableIncome * 0.30)
steps.push({
label: 'Skatteavdrag (30% schablon)',
formula: 'taxable_income × 30%',
input: { taxable_income: taxableIncome },
output: taxWithheld,
})
}
// ─── Step 7: Net salary ───
const netDeductionItems = input.lineItems.filter(li => li.isNetDeduction)
const totalNetDeductions = r(Math.abs(netDeductionItems.reduce((sum, li) => sum + li.amount, 0)))
const netSalary = r(grossSalary - taxWithheld - totalNetDeductions)
steps.push({
label: 'Nettolön',
formula: 'gross - tax - net_deductions',
input: { gross: grossSalary, tax: taxWithheld, net_deductions: totalNetDeductions },
output: netSalary,
})
// ─── Step 8: Employer contributions (avgifter) ───
const avgifterCalc = calculateAvgifterRate(input, config, paymentYear)
const avgifterBasis = r(grossSalary + totalBenefits)
// Handle salary caps for youth and växa-stöd:
// Reduced rate applies only up to the cap, standard rate on the rest
let avgifterAmount: number
if (avgifterCalc.category === 'youth' && config.avgifterYouthSalaryCap && avgifterBasis > config.avgifterYouthSalaryCap) {
const reducedPart = r(config.avgifterYouthSalaryCap * avgifterCalc.rate)
const standardPart = r((avgifterBasis - config.avgifterYouthSalaryCap) * config.avgifterTotal)
avgifterAmount = r(reducedPart + standardPart)
steps.push(...avgifterCalc.steps)
steps.push({
label: 'Arbetsgivaravgifter (ungdomsrabatt med tak)',
formula: `${config.avgifterYouthSalaryCap} × ${avgifterCalc.rate} + ${r(avgifterBasis - config.avgifterYouthSalaryCap)} × ${config.avgifterTotal}`,
input: { cap: config.avgifterYouthSalaryCap, reduced: reducedPart, standard: standardPart },
output: avgifterAmount,
})
} else if (avgifterCalc.category === 'vaxa_stod' && config.avgifterVaxaStodCap && avgifterBasis > config.avgifterVaxaStodCap) {
const reducedPart = r(config.avgifterVaxaStodCap * avgifterCalc.rate)
const standardPart = r((avgifterBasis - config.avgifterVaxaStodCap) * config.avgifterTotal)
avgifterAmount = r(reducedPart + standardPart)
steps.push(...avgifterCalc.steps)
steps.push({
label: 'Arbetsgivaravgifter (växa-stöd med tak)',
formula: `${config.avgifterVaxaStodCap} × ${avgifterCalc.rate} + ${r(avgifterBasis - config.avgifterVaxaStodCap)} × ${config.avgifterTotal}`,
input: { cap: config.avgifterVaxaStodCap, reduced: reducedPart, standard: standardPart },
output: avgifterAmount,
})
} else {
avgifterAmount = r(avgifterBasis * avgifterCalc.rate)
steps.push(...avgifterCalc.steps)
steps.push({
label: 'Arbetsgivaravgifter',
formula: 'avgifter_basis × rate',
input: { avgifter_basis: avgifterBasis, rate: avgifterCalc.rate },
output: avgifterAmount,
})
}
// ─── Step 9: Vacation accrual ───
const vacationBasisItems = input.lineItems.filter(li => li.isVacationBasis)
const vacationBasis = r(
baseSalary + vacationBasisItems.reduce((sum, li) => sum + li.amount, 0)
)
let vacationAccrual: number
if (input.vacationRule === 'procentregeln') {
const rate = input.vacationDaysPerYear >= 30 ? 0.144 : 0.12
vacationAccrual = r(vacationBasis * rate)
steps.push({
label: `Semesteravsättning (procentregeln ${rate * 100}%)`,
formula: 'vacation_basis × rate',
input: { vacation_basis: vacationBasis, rate },
output: vacationAccrual,
})
} else {
// Sammalöneregeln (§16a): employee keeps regular salary during vacation
// + semestertillägg per day (min 0.43%, often 0.8% per CBA)
// Accrual = tillägg only (salary cost is already in normal monthly expense)
// The liability (2920) for sammalöneregeln is the tillägg portion,
// since the base salary is expensed monthly regardless of vacation.
const dailyRate = r(input.monthlySalary / 21)
const tillagg = r(dailyRate * input.semestertillaggRate * input.vacationDaysPerYear)
vacationAccrual = tillagg
steps.push({
label: `Semesteravsättning (sammalöneregeln, tillägg ${(input.semestertillaggRate * 100).toFixed(2)}%)`,
formula: 'daily_rate × semestertillagg_rate × vacation_days',
input: { daily_rate: dailyRate, semestertillagg_rate: input.semestertillaggRate, vacation_days: input.vacationDaysPerYear },
output: vacationAccrual,
})
}
// ─── Step 10: Avgifter on vacation accrual ───
const vacationAccrualAvgifter = r(vacationAccrual * avgifterCalc.rate)
steps.push({
label: 'Arbetsgivaravgifter på semesteravsättning',
formula: 'vacation_accrual × avgifter_rate',
input: { vacation_accrual: vacationAccrual, avgifter_rate: avgifterCalc.rate },
output: vacationAccrualAvgifter,
})
const totalEmployerCost = r(grossSalary + avgifterAmount + vacationAccrual + vacationAccrualAvgifter)
steps.push({
label: 'Total arbetsgivarkostnad',
formula: 'gross + avgifter + vacation_accrual + vacation_avgifter',
input: { gross: grossSalary, avgifter: avgifterAmount, vacation_accrual: vacationAccrual, vacation_avgifter: vacationAccrualAvgifter },
output: totalEmployerCost,
})
return {
grossSalary,
grossDeductions: totalGrossDeductions,
benefitValues: totalBenefits,
taxableIncome,
taxWithheld,
netDeductions: totalNetDeductions,
netSalary,
avgifterRate: avgifterCalc.rate,
avgifterAmount,
avgifterBasis,
avgifterCategory: avgifterCalc.category,
vacationAccrual,
vacationAccrualAvgifter,
totalEmployerCost,
steps,
}
}
// ============================================================
// Avgifter calculation
// ============================================================
/**
* Determine arbetsgivaravgifter rate based on employee age, växa-stöd, etc.
*/
export function calculateAvgifterRate(
input: SalaryCalculationInput,
config: PayrollConfig,
paymentYear: number
): AvgifterCalculation {
const steps: CalculationStep[] = []
// Decrypt personnummer to calculate age
let pnr: string
try {
pnr = decryptPersonnummer(input.personnummer)
} catch {
// If decryption fails, assume standard rate
return {
rate: config.avgifterTotal,
amount: 0,
basis: 0,
category: 'standard',
steps: [{ label: 'Avgiftskategori', formula: 'standard (personnummer ej dekrypterbart)', input: {}, output: config.avgifterTotal }],
}
}
const ageAtYearStart = calculateAgeAtYearStart(pnr, paymentYear)
// Born ≤1937: 0%
const birthYear = parseInt(pnr.slice(0, 4))
if (birthYear <= 1937) {
steps.push({
label: 'Avgiftskategori',
formula: 'Född ≤1937: 0%',
input: { birth_year: birthYear },
output: 0,
})
return { rate: 0, amount: 0, basis: 0, category: 'exempt', steps }
}
// 67+ at year start (reduced — only ålderspension)
if (ageAtYearStart >= config.reducedAvgiftAge) {
steps.push({
label: 'Avgiftskategori',
formula: `Ålder ${ageAtYearStart} ≥ ${config.reducedAvgiftAge}: reducerad (${config.avgifterReduced65plus * 100}%)`,
input: { age: ageAtYearStart, threshold: config.reducedAvgiftAge },
output: config.avgifterReduced65plus,
})
return { rate: config.avgifterReduced65plus, amount: 0, basis: 0, category: 'reduced_65plus', steps }
}
// Växa-stöd eligible
if (input.vaxaStodEligible && input.vaxaStodStart && input.vaxaStodEnd) {
const payDate = input.paymentDate
if (payDate >= input.vaxaStodStart && payDate <= input.vaxaStodEnd && config.avgifterVaxaStodRate !== null) {
steps.push({
label: 'Avgiftskategori',
formula: `Växa-stöd: ${(config.avgifterVaxaStodRate ?? 0) * 100}% på första ${config.avgifterVaxaStodCap} SEK`,
input: { vaxa_cap: config.avgifterVaxaStodCap ?? 0 },
output: config.avgifterVaxaStodRate ?? 0,
})
return { rate: config.avgifterVaxaStodRate ?? config.avgifterTotal, amount: 0, basis: 0, category: 'vaxa_stod', steps }
}
}
// Youth rate (2026: ages 19-23, Apr-Sep only)
if (config.avgifterYouthRate !== null && ageAtYearStart >= 19 && ageAtYearStart <= 23) {
const [, monthStr] = input.paymentDate.split('-')
const month = parseInt(monthStr)
// Youth rate valid Apr 2026 - Sep 2027
const isYouthPeriod = (paymentYear === 2026 && month >= 4) || (paymentYear === 2027 && month <= 9)
if (isYouthPeriod) {
steps.push({
label: 'Avgiftskategori',
formula: `Ungdomsrabatt (${ageAtYearStart} år): ${config.avgifterYouthRate * 100}% på första ${config.avgifterYouthSalaryCap} SEK`,
input: { age: ageAtYearStart, cap: config.avgifterYouthSalaryCap ?? 0 },
output: config.avgifterYouthRate,
})
return { rate: config.avgifterYouthRate, amount: 0, basis: 0, category: 'youth', steps }
}
}
// Standard rate
steps.push({
label: 'Avgiftskategori',
formula: `Standard: ${config.avgifterTotal * 100}%`,
input: { age: ageAtYearStart },
output: config.avgifterTotal,
})
return { rate: config.avgifterTotal, amount: 0, basis: 0, category: 'standard', steps }
}
// ============================================================
// Sjuklön helpers
// ============================================================
/**
* Calculate karensavdrag (sick leave deduction day 1).
* Formula: 20% × (monthly_salary × 12 / 52 × sjuklön_rate)
*/
export function calculateKarensavdrag(monthlySalary: number, config: PayrollConfig): number {
const weeklySjuklon = r(monthlySalary * 12 / 52 * config.sjuklonRate)
return r(weeklySjuklon * config.karensavdragFactor)
}
/**
* Calculate sjuklön for days 2-14.
* Formula: 80% × daily_rate × (sick_days - 1)
*/
export function calculateSjuklon(
monthlySalary: number,
sickDays: number,
config: PayrollConfig
): { karensavdrag: number; sjuklon: number; totalDeduction: number; steps: CalculationStep[] } {
const steps: CalculationStep[] = []
const dailyRate = r(monthlySalary / 21)
// Karensavdrag
const karensavdrag = calculateKarensavdrag(monthlySalary, config)
steps.push({
label: 'Karensavdrag',
formula: '20% × (monthly × 12/52 × 80%)',
input: { monthly_salary: monthlySalary },
output: karensavdrag,
})
// Sjuklön day 2-14
const sjuklonDays = Math.min(Math.max(sickDays - 1, 0), 13)
const sjuklon = r(dailyRate * config.sjuklonRate * sjuklonDays)
steps.push({
label: 'Sjuklön dag 2-14',
formula: 'daily_rate × 80% × (sick_days - 1)',
input: { daily_rate: dailyRate, sjuklon_rate: config.sjuklonRate, days: sjuklonDays },
output: sjuklon,
})
// Total deduction from pay = salary they would have earned - sjuklön they get
const fullPayForPeriod = r(dailyRate * sickDays)
const totalDeduction = r(-(fullPayForPeriod - sjuklon + karensavdrag))
steps.push({
label: 'Netto sjukavdrag',
formula: '-(full_pay - sjuklon + karensavdrag)',
input: { full_pay: fullPayForPeriod, sjuklon, karensavdrag },
output: totalDeduction,
})
return { karensavdrag, sjuklon, totalDeduction, steps }
}
/**
* Calculate vacation accrual.
*/
export function calculateVacationAccrual(params: {
monthlySalary: number
vacationRule: 'procentregeln' | 'sammaloneregeln'
vacationDaysPerYear: number
semestertillaggRate: number
vacationBasis: number
}): { accrual: number; steps: CalculationStep[] } {
const steps: CalculationStep[] = []
if (params.vacationRule === 'procentregeln') {
const rate = params.vacationDaysPerYear >= 30 ? 0.144 : 0.12
const accrual = r(params.vacationBasis * rate)
steps.push({
label: `Semesteravsättning (procentregeln ${rate * 100}%)`,
formula: 'vacation_basis × rate',
input: { vacation_basis: params.vacationBasis, rate },
output: accrual,
})
return { accrual, steps }
} else {
const dailyRate = r(params.monthlySalary / 21)
const accrual = r(dailyRate * params.semestertillaggRate * params.vacationDaysPerYear)
steps.push({
label: `Semesteravsättning (sammalöneregeln ${params.semestertillaggRate * 100}%)`,
formula: 'daily_rate × semestertillagg_rate × vacation_days',
input: { daily_rate: dailyRate, rate: params.semestertillaggRate, days: params.vacationDaysPerYear },
output: accrual,
})
return { accrual, steps }
}
}
// ============================================================
// Helpers
// ============================================================
function isJamkningValid(
validFrom: string | null,
validTo: string | null,
paymentDate: string
): boolean {
if (!validFrom || !validTo) return false
return paymentDate >= validFrom && paymentDate <= validTo
}