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>
This commit is contained in:
Mattsson
2026-08-03 17:26:49 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6782da3e9e
commit c9625fa45c
8 changed files with 143 additions and 13 deletions
+7
View File
@@ -41,6 +41,7 @@ const makeRun = (overrides: Record<string, unknown> = {}) => ({
total_net: 23000,
total_avgifter: 9426,
total_vacation_accrual: 0,
calculation_params: { slpRate: 0.2426 },
...overrides,
})
@@ -194,5 +195,11 @@ describe('bookPaidSalaryRun', () => {
expect(result.ok).toBe(true)
if (result.ok) expect(result.data.entryIds).toEqual(['je-1', 'je-2'])
expect(createSalaryRunEntries).toHaveBeenCalledTimes(1)
expect(createSalaryRunEntries).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ calculation_params: { slpRate: 0.2426 } }),
)
})
})
@@ -83,6 +83,7 @@ function makeRun(employees: ReturnType<typeof makeEmployee>[]) {
total_net: employees.reduce((s, e) => s + e.net_salary, 0),
total_avgifter: employees.reduce((s, e) => s + e.avgifter_amount, 0),
total_vacation_accrual: employees.reduce((s, e) => s + e.vacation_accrual, 0),
calculation_params: { slpRate: 0.2426 },
employees,
}
}
@@ -276,6 +277,36 @@ describe('salary entries: dimensions propagation (PR8)', () => {
assertBalanced(pension)
})
it('derives pension + SLP from gross_deduction_pension line items', async () => {
const run = makeRun([
makeEmployee({
employee_id: 'a',
default_dimensions: { '1': 'KS01' },
line_items: [
{
item_type: 'gross_deduction_pension',
amount: -2000,
account_number: '7218',
is_net_deduction: false,
is_gross_deduction: true,
},
],
}),
])
const result = await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const pension = entryByDescription('Pensionsavsättning')
expect(result.pensionEntry).not.toBeNull()
expect(linesOn(pension, '7410')).toEqual([
expect.objectContaining({ debit_amount: 2116, dimensions: { '1': 'KS01' } }),
])
expect(linesOn(pension, '2740')[0].credit_amount).toBe(2116)
expect(linesOn(pension, '7533')[0].debit_amount).toBe(513.34)
expect(linesOn(pension, '2514')[0].credit_amount).toBe(513.34)
assertBalanced(pension)
})
it('rejects an invalid bag (coerce gate) rather than booking junk keys', async () => {
const run = makeRun([
makeEmployee({ employee_id: 'a', default_dimensions: { '0': 'BAD' } as Record<string, string> }),
+3
View File
@@ -151,6 +151,9 @@ async function bookLoadedRun(
total_net: run.total_net as number,
total_avgifter: run.total_avgifter as number,
total_vacation_accrual: run.total_vacation_accrual as number,
// Use the exact payroll-rate snapshot approved with this run. Reading
// current config here could change SLP between calculation and booking.
calculation_params: run.calculation_params as Record<string, unknown> | null,
employees: roster.map((sre) => ({
employee_id: sre.employee_id,
employment_type: sre.employee?.employment_type || 'employee',
+33 -2
View File
@@ -1,3 +1,4 @@
import { roundOre } from '@/lib/money'
import type { PayrollConfig } from './payroll-config'
/**
@@ -31,6 +32,34 @@ export interface LoneVaxlingStep {
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.
*
@@ -50,7 +79,10 @@ export function calculateLoneVaxling(
const warnings: string[] = []
const factor = 1.058
const pensionContribution = r(reductionAmount * factor)
const { pensionContribution, slpOnPension } = calculateLoneVaxlingPensionProvision(
reductionAmount,
config.slpRate,
)
steps.push({
label: 'Pensionsavsättning',
formula: 'reduction × 1.058',
@@ -76,7 +108,6 @@ export function calculateLoneVaxling(
})
// SLP (Särskild löneskatt) on pension: 24.26%
const slpOnPension = r(pensionContribution * config.slpRate)
steps.push({
label: 'Särskild löneskatt på pension',
formula: 'pension × SLP_rate',
+49 -9
View File
@@ -8,6 +8,7 @@ import {
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { SALARY_ACCOUNTS, getLineItemAccount } from './account-mapping'
import { calculateLoneVaxlingPensionProvision } from './lonevaxling'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
CreateJournalEntryInput,
@@ -58,9 +59,47 @@ interface SalaryRunData {
total_net: number
total_avgifter: number
total_vacation_accrual: number
calculation_params?: Record<string, unknown> | null
employees: SalaryRunEmployee[]
}
function resolveLoneVaxlingPension(run: SalaryRunData): SalaryRunData {
const snapshotSlpRate = run.calculation_params?.slpRate
return {
...run,
employees: run.employees.map((employee) => {
// Preserve the explicit amounts accepted by this low-level API. Current
// booking callers omit them and derive from the frozen line-item set.
if (
employee.pension_contribution !== undefined ||
employee.pension_slp !== undefined
) {
return employee
}
const salaryReduction = employee.line_items
.filter((line) => line.item_type === 'gross_deduction_pension')
.reduce((sum, line) => sum + line.amount, 0)
if (roundOre(Math.abs(salaryReduction)) === 0) return employee
if (typeof snapshotSlpRate !== 'number') {
throw new Error('Salary run calculation snapshot is missing the SLP rate')
}
const provision = calculateLoneVaxlingPensionProvision(
salaryReduction,
snapshotSlpRate,
)
return {
...employee,
pension_contribution: provision.pensionContribution,
pension_slp: provision.slpOnPension,
}
}),
}
}
/**
* Create all journal entries for a salary run.
* Creates 3 entries:
@@ -81,6 +120,7 @@ export async function createSalaryRunEntries(
vacationEntry: JournalEntry | null
pensionEntry: JournalEntry | null
}> {
const postingRun = resolveLoneVaxlingPension(run)
const entryDate = run.payment_date
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
if (!fiscalPeriodId) {
@@ -90,25 +130,25 @@ export async function createSalaryRunEntries(
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
const desc = `Lön ${periodLabel}`
await ensureSalaryAccountsExist(supabase, companyId, userId, run)
await ensureSalaryAccountsExist(supabase, companyId, userId, postingRun)
// ─── Entry 1: Salary (brutto, skatt, netto) ───
const salaryEntry = await createSalaryEntry(
supabase, companyId, userId, run, fiscalPeriodId, desc
supabase, companyId, userId, postingRun, fiscalPeriodId, desc
)
// ─── Entry 2: Arbetsgivaravgifter ───
const avgifterEntry = await createAvgifterEntry(
supabase, companyId, userId, run, fiscalPeriodId, desc
supabase, companyId, userId, postingRun, fiscalPeriodId, desc
)
// ─── Entry 3: Vacation accrual (if any) ───
let vacationEntry: JournalEntry | null = null
const totalVacation = run.employees.reduce((sum, e) => sum + e.vacation_accrual, 0)
const totalVacationAvgifter = run.employees.reduce((sum, e) => sum + e.vacation_accrual_avgifter, 0)
const totalVacation = postingRun.employees.reduce((sum, e) => sum + e.vacation_accrual, 0)
const totalVacationAvgifter = postingRun.employees.reduce((sum, e) => sum + e.vacation_accrual_avgifter, 0)
if (totalVacation > 0 || totalVacationAvgifter > 0) {
vacationEntry = await createVacationEntry(
supabase, companyId, userId, run, fiscalPeriodId, desc, totalVacation, totalVacationAvgifter
supabase, companyId, userId, postingRun, fiscalPeriodId, desc, totalVacation, totalVacationAvgifter
)
}
@@ -117,11 +157,11 @@ export async function createSalaryRunEntries(
// Debit 7410 Pensionsförsäkringspremier / Credit 2740 Skuld pensionsförsäkringar
// Debit 7533 Särskild löneskatt / Credit 2514 Beräknad särskild löneskatt
let pensionEntry: JournalEntry | null = null
const totalPension = run.employees.reduce((sum, e) => sum + (e.pension_contribution || 0), 0)
const totalSlp = run.employees.reduce((sum, e) => sum + (e.pension_slp || 0), 0)
const totalPension = postingRun.employees.reduce((sum, e) => sum + (e.pension_contribution || 0), 0)
const totalSlp = postingRun.employees.reduce((sum, e) => sum + (e.pension_slp || 0), 0)
if (totalPension > 0) {
pensionEntry = await createPensionEntry(
supabase, companyId, userId, run, fiscalPeriodId, desc, totalPension, totalSlp
supabase, companyId, userId, postingRun, fiscalPeriodId, desc, totalPension, totalSlp
)
}