11621bb79f
* feat: add script to import Skatteverket monthly tax tables as fallback TypeScript module
- Implemented a new script `import-tax-tables.ts` to parse fixed-width TXT tax tables from Skatteverket (SKV 434).
- The script generates a TypeScript module for emergency fallback when the Skatteverket open-data API is unavailable.
- Supports command-line argument for specifying the year and handles parsing of B-rows only.
- Outputs a structured TypeScript file containing tax data for specified years.
* feat: gate salary module behind dev-only flag
Temporarily disable the Lön module in production while the feature is
being completed. Sidebar entries ("Löner", "Anställda") still render but
are not clickable and show a "Kommer snart" badge. Middleware redirects
/salary* to / and returns 404 on /api/salary/* so the feature can't be
reached by direct URL. All gates check NODE_ENV === 'development' so
local dev keeps full access for continued development.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: refactor bank file import wizard to streamline column mapping and enhance CSV handling
* fix: bump migration timestamp to avoid collision with logos_bucket
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: enhance AGI generation and salary entry calculations with improved status checks and error handling
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
624 lines
22 KiB
TypeScript
624 lines
22 KiB
TypeScript
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>
|
||
/** Numeric result for the step. `null` for context-only rows (e.g. avgiftskategori) that describe a rule, not a calculation. */
|
||
output: number | null
|
||
}
|
||
|
||
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 / formatting helpers
|
||
// ============================================================
|
||
|
||
function r(x: number): number {
|
||
return Math.round(x * 100) / 100
|
||
}
|
||
|
||
/**
|
||
* Format a rate (0.2081) as a Swedish percentage string ("20,81 %").
|
||
* Strips trailing zeros, uses Swedish comma as decimal separator, and rounds
|
||
* to avoid JS floating-point noise like "20.810000000000002".
|
||
*/
|
||
function fmtPct(decimal: number, decimals = 2): string {
|
||
const pct = decimal * 100
|
||
const rounded = Math.round(pct * 10 ** decimals) / 10 ** decimals
|
||
const str = rounded
|
||
.toFixed(decimals)
|
||
.replace(/\.?0+$/, '')
|
||
.replace('.', ',')
|
||
return `${str} %`
|
||
}
|
||
|
||
/**
|
||
* Format an integer amount with Swedish thousand-separators and "kr" suffix,
|
||
* for embedding inside formula descriptions ("25 000 kr").
|
||
*/
|
||
function fmtKr(amount: number): string {
|
||
return `${Math.round(amount).toLocaleString('sv-SE')} kr`
|
||
}
|
||
|
||
// ============================================================
|
||
// 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: 'månadslön × (sysselsättningsgrad / 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: 'timlön × arbetade timmar',
|
||
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: 'summa tillägg',
|
||
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: 'summa frånvaroposter',
|
||
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: 'summa bruttoavdrag',
|
||
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: 'grundlön + tillägg + frånvaro − bruttoavdrag',
|
||
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: 'summa förmåner',
|
||
input: { count: benefitItems.length },
|
||
output: totalBenefits,
|
||
})
|
||
}
|
||
|
||
const taxableIncome = r(grossSalary + totalBenefits)
|
||
steps.push({
|
||
label: 'Skattegrundande inkomst',
|
||
formula: 'bruttolön + förmåner',
|
||
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: 'F-skattsedel — inget skatteavdrag görs',
|
||
input: {},
|
||
output: 0,
|
||
})
|
||
} else if (input.fSkattStatus === 'not_verified') {
|
||
// Unverified: flat 30%
|
||
taxWithheld = r(taxableIncome * 0.30)
|
||
steps.push({
|
||
label: 'Skatteavdrag (ej verifierad)',
|
||
formula: 'skattegrundande inkomst × 30 %',
|
||
input: { taxable_income: taxableIncome },
|
||
output: taxWithheld,
|
||
})
|
||
} else if (input.isSidoinkomst) {
|
||
// Sidoinkomst: flat 30%
|
||
taxWithheld = calculateSidoinkomstTax(taxableIncome)
|
||
steps.push({
|
||
label: 'Skatteavdrag (sidoinkomst 30 %)',
|
||
formula: 'skattegrundande inkomst × 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: `skattegrundande inkomst × ${input.jamkningPercentage} %`,
|
||
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: `skattetabell ${input.taxTableNumber}, kolumn ${input.taxColumn}, inkomst ${fmtKr(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: 'skattegrundande inkomst × 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: 'bruttolön − skatt − nettoavdrag',
|
||
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: `${fmtKr(config.avgifterYouthSalaryCap)} × ${fmtPct(avgifterCalc.rate)} + ${fmtKr(avgifterBasis - config.avgifterYouthSalaryCap)} × ${fmtPct(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: `${fmtKr(config.avgifterVaxaStodCap)} × ${fmtPct(avgifterCalc.rate)} + ${fmtKr(avgifterBasis - config.avgifterVaxaStodCap)} × ${fmtPct(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: `avgiftsunderlag × ${fmtPct(avgifterCalc.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 ${fmtPct(rate)})`,
|
||
formula: `semesterunderlag × ${fmtPct(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 ${fmtPct(input.semestertillaggRate)})`,
|
||
formula: `dagslön × ${fmtPct(input.semestertillaggRate)} × semesterdagar`,
|
||
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: `semesteravsättning × ${fmtPct(avgifterCalc.rate)}`,
|
||
input: { vacation_accrual: vacationAccrual, avgifter_rate: avgifterCalc.rate },
|
||
output: vacationAccrualAvgifter,
|
||
})
|
||
|
||
const totalEmployerCost = r(grossSalary + avgifterAmount + vacationAccrual + vacationAccrualAvgifter)
|
||
steps.push({
|
||
label: 'Total arbetsgivarkostnad',
|
||
formula: 'bruttolön + avgifter + semesteravsättning + avgifter på semester',
|
||
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 ${fmtPct(config.avgifterTotal)} (personnummer kunde inte dekrypteras)`,
|
||
input: {},
|
||
output: null,
|
||
}],
|
||
}
|
||
}
|
||
|
||
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 eller tidigare — inga arbetsgivaravgifter',
|
||
input: { birth_year: birthYear },
|
||
output: null,
|
||
})
|
||
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} år: reducerad avgift ${fmtPct(config.avgifterReduced65plus)} (endast ålderspensionsavgift)`,
|
||
input: { age: ageAtYearStart, threshold: config.reducedAvgiftAge },
|
||
output: null,
|
||
})
|
||
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 ${fmtPct(config.avgifterVaxaStodRate ?? 0)} på första ${fmtKr(config.avgifterVaxaStodCap ?? 0)}`,
|
||
input: { vaxa_cap: config.avgifterVaxaStodCap ?? 0 },
|
||
output: null,
|
||
})
|
||
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): ${fmtPct(config.avgifterYouthRate)} på första ${fmtKr(config.avgifterYouthSalaryCap ?? 0)}`,
|
||
input: { age: ageAtYearStart, cap: config.avgifterYouthSalaryCap ?? 0 },
|
||
output: null,
|
||
})
|
||
return { rate: config.avgifterYouthRate, amount: 0, basis: 0, category: 'youth', steps }
|
||
}
|
||
}
|
||
|
||
// Standard rate
|
||
steps.push({
|
||
label: 'Avgiftskategori',
|
||
formula: `Standard ${fmtPct(config.avgifterTotal)}`,
|
||
input: { age: ageAtYearStart },
|
||
output: null,
|
||
})
|
||
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 % × (månadslön × 12/52 × ${fmtPct(config.sjuklonRate)})`,
|
||
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: `dagslön × ${fmtPct(config.sjuklonRate)} × (sjukdagar − 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 lön − sjuklön + 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 ${fmtPct(rate)})`,
|
||
formula: `semesterunderlag × ${fmtPct(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 ${fmtPct(params.semestertillaggRate)})`,
|
||
formula: `dagslön × ${fmtPct(params.semestertillaggRate)} × semesterdagar`,
|
||
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
|
||
}
|