Files
accounted/lib/salary/lonevaxling.ts
T
Mattsson c9625fa45c fix(salary): book lonevaxling pension provision from frozen run snapshot (#1382)
The book flow never populated pension_contribution/pension_slp, so a
gross_deduction_pension line item reduced the salary entry but the
7410/2740 pension provision and 7533/2514 SLP lines were never posted.
Derive both at the createSalaryRunEntries boundary from the run's frozen
calculation_params.slpRate snapshot so the dashboard, MCP and v1 booking
paths all emit the pension verifikat, and reuse the exact 1.058 factor
via calculateLoneVaxlingPensionProvision shared with the planning
calculator.

Fixes #317

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 17:26:49 +02:00

157 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { roundOre } from '@/lib/money'
import type { PayrollConfig } from './payroll-config'
/**
* Löneväxling till pension: salary sacrifice to occupational pension.
*
* The 1.058 factor: For every 1 SEK salary reduction, pension contribution
* should be 1.058 SEK to make the employee roughly cost-neutral
* (because reduced salary → reduced avgifter for employer).
*
* Warnings:
* - Post-reduction salary < 8.07 × IBB / 12 reduces PGI and SGI
* - Employer pension cap: 35% of pensionsmedförande lön or 10 × PBB/year
*/
export interface LoneVaxlingResult {
salaryReduction: number
pensionContribution: number
preReductionSalary: number
postReductionSalary: number
savedAvgifter: number
slpOnPension: number
netEmployerSaving: number
warnings: string[]
steps: LoneVaxlingStep[]
}
export interface LoneVaxlingStep {
label: string
formula: string
input: Record<string, number | string>
output: number
}
export interface LoneVaxlingPensionProvision {
salaryReduction: number
pensionContribution: number
slpOnPension: number
}
/**
* Calculate the journal amounts created by a pension salary exchange.
*
* Kept separate from the warning calculation so the booking path can use the
* exact same 1.058 factor and SLP rounding without inventing a current salary
* solely to call calculateLoneVaxling().
*/
export function calculateLoneVaxlingPensionProvision(
reductionAmount: number,
slpRate: number,
): LoneVaxlingPensionProvision {
if (!Number.isFinite(slpRate) || slpRate < 0 || slpRate > 1) {
throw new Error('Payroll configuration has an invalid SLP rate')
}
const salaryReduction = roundOre(Math.abs(reductionAmount))
const pensionContribution = roundOre(salaryReduction * 1.058)
const slpOnPension = roundOre(pensionContribution * slpRate)
return { salaryReduction, pensionContribution, slpOnPension }
}
/**
* Calculate löneväxling impact.
*
* @param reductionAmount - Monthly bruttolöneavdrag amount
* @param currentMonthlySalary - Current gross monthly salary before reduction
* @param config - Payroll config for the year
* @param avgifterRate - Employee's applicable avgifter rate (may differ from standard)
*/
export function calculateLoneVaxling(
reductionAmount: number,
currentMonthlySalary: number,
config: PayrollConfig,
avgifterRate: number = 0.3142
): LoneVaxlingResult {
const r = (x: number) => Math.round(x * 100) / 100
const steps: LoneVaxlingStep[] = []
const warnings: string[] = []
const factor = 1.058
const { pensionContribution, slpOnPension } = calculateLoneVaxlingPensionProvision(
reductionAmount,
config.slpRate,
)
steps.push({
label: 'Pensionsavsättning',
formula: 'reduction × 1.058',
input: { reduction: reductionAmount, factor },
output: pensionContribution,
})
const postReductionSalary = r(currentMonthlySalary - reductionAmount)
steps.push({
label: 'Ny bruttolön',
formula: 'current - reduction',
input: { current: currentMonthlySalary, reduction: reductionAmount },
output: postReductionSalary,
})
// Avgifter savings
const savedAvgifter = r(reductionAmount * avgifterRate)
steps.push({
label: 'Sparade arbetsgivaravgifter',
formula: 'reduction × avgifter_rate',
input: { reduction: reductionAmount, avgifter_rate: avgifterRate },
output: savedAvgifter,
})
// SLP (Särskild löneskatt) on pension: 24.26%
steps.push({
label: 'Särskild löneskatt på pension',
formula: 'pension × SLP_rate',
input: { pension: pensionContribution, slp_rate: config.slpRate },
output: slpOnPension,
})
// Net employer cost difference
const netEmployerSaving = r(savedAvgifter - pensionContribution - slpOnPension + reductionAmount)
steps.push({
label: 'Nettoresultat arbetsgivare',
formula: 'saved_avgifter - pension - slp + reduction',
input: { saved_avgifter: savedAvgifter, pension: pensionContribution, slp: slpOnPension, reduction: reductionAmount },
output: netEmployerSaving,
})
// Warning: PGI/SGI floor
const pgiFloor = r(8.07 * config.inkomstbasbelopp / 12)
if (postReductionSalary < pgiFloor) {
warnings.push(
`Varning: Ny lön ${r(postReductionSalary)} SEK < PGI-golv ${r(pgiFloor)} SEK/mån. ` +
`Reducerad allmän pension och sjukpenninggrundande inkomst (SGI).`
)
}
// Warning: Pension cap
const annualPensionCap = r(10 * config.prisbasbelopp)
const annualContribution = r(pensionContribution * 12)
if (annualContribution > annualPensionCap) {
warnings.push(
`Varning: Årsvis pensionsavsättning ${r(annualContribution)} SEK överstiger taket ${r(annualPensionCap)} SEK (10 × PBB).`
)
}
return {
salaryReduction: reductionAmount,
pensionContribution,
preReductionSalary: currentMonthlySalary,
postReductionSalary,
savedAvgifter,
slpOnPension,
netEmployerSaving,
warnings,
steps,
}
}