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:
co-authored by
Claude Fable 5
parent
6782da3e9e
commit
c9625fa45c
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and humans when a non-obvious choice is made (approach picked over an alternative, dependency declined, action stopped by a CLAUDE.md rule). Read before re-litigating a past decision.
|
One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and humans when a non-obvious choice is made (approach picked over an alternative, dependency declined, action stopped by a CLAUDE.md rule). Read before re-litigating a past decision.
|
||||||
|
|
||||||
|
[2026-08-03] Issue #317 derives löneväxling pension and SLP at the shared salary-entry boundary from the frozen salary_runs.calculation_params rate snapshot: this fixes dashboard, MCP, and v1 booking without a schema migration, and prevents a live config change from altering an already reviewed run between calculation and posting.
|
||||||
|
|
||||||
[2026-08-01] Invoice payment dates stored in timestamptz use a shared UTC-noon representation: `paid_at` has date-only business semantics, and noon preserves the selected or bank transaction date when formatted in UTC, Europe/Stockholm, and all negative UTC offsets through UTC-12; UTC midnight displays as the prior day in American time zones.
|
[2026-08-01] Invoice payment dates stored in timestamptz use a shared UTC-noon representation: `paid_at` has date-only business semantics, and noon preserves the selected or bank transaction date when formatted in UTC, Europe/Stockholm, and all negative UTC offsets through UTC-12; UTC midnight displays as the prior day in American time zones.
|
||||||
[2026-08-01] Privately paid supplier-invoice creation stays outside the payment-date correction: that flow deliberately journals on the invoice date while its optional out-of-pocket payment date can differ, so changing only `paid_at` would require a separate Swedish accounting semantics decision.
|
[2026-08-01] Privately paid supplier-invoice creation stays outside the payment-date correction: that flow deliberately journals on the invoice date while its optional out-of-pocket payment date can differ, so changing only `paid_at` would require a separate Swedish accounting semantics decision.
|
||||||
|
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ describe('POST /salary-runs/:id/book', () => {
|
|||||||
total_net: 25500,
|
total_net: 25500,
|
||||||
total_avgifter: 10997,
|
total_avgifter: 10997,
|
||||||
total_vacation_accrual: 0,
|
total_vacation_accrual: 0,
|
||||||
|
calculation_params: { slpRate: 0.2426 },
|
||||||
}
|
}
|
||||||
|
|
||||||
const employeeRow = {
|
const employeeRow = {
|
||||||
@@ -478,6 +479,12 @@ describe('POST /salary-runs/:id/book', () => {
|
|||||||
expect(body.data.entry_ids).toEqual(['je_salary', 'je_avg'])
|
expect(body.data.entry_ids).toEqual(['je_salary', 'je_avg'])
|
||||||
expect(body.meta.audit.voucher_number).toBe('L2026-0023')
|
expect(body.meta.audit.voucher_number).toBe('L2026-0023')
|
||||||
expect(body.meta.audit.voucher_url).toContain('je_salary')
|
expect(body.meta.audit.voucher_url).toContain('je_salary')
|
||||||
|
expect(mocks.createSalaryRunEntries).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
COMPANY_ID,
|
||||||
|
USER_ID,
|
||||||
|
expect.objectContaining({ calculation_params: { slpRate: 0.2426 } }),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns PERIOD_LOCKED before invoking the engine when payment_date is locked', async () => {
|
it('returns PERIOD_LOCKED before invoking the engine when payment_date is locked', async () => {
|
||||||
|
|||||||
@@ -190,8 +190,16 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
|||||||
'salary (gross + tax withholding + net payment)',
|
'salary (gross + tax withholding + net payment)',
|
||||||
'arbetsgivaravgifter',
|
'arbetsgivaravgifter',
|
||||||
...(totalVacation > 0 || totalVacationAvgifter > 0 ? ['vacation accrual'] : []),
|
...(totalVacation > 0 || totalVacationAvgifter > 0 ? ['vacation accrual'] : []),
|
||||||
// Pension cannot be detected without the engine: we'd need
|
...((employees as Array<{ line_items: Array<{ item_type: string; amount: number }> | null }>).some(
|
||||||
// to inspect line_items for löneväxling. Omitted from preview.
|
(employee) =>
|
||||||
|
(employee.line_items || []).some(
|
||||||
|
(line) =>
|
||||||
|
line.item_type === 'gross_deduction_pension' &&
|
||||||
|
Math.round(Math.abs(line.amount) * 100) !== 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
? ['pension provision and SLP']
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
note: 'A live call posts 2-4 verifikationer atomically via createSalaryRunEntries. Voucher numbers are assigned at commit time.',
|
note: 'A live call posts 2-4 verifikationer atomically via createSalaryRunEntries. Voucher numbers are assigned at commit time.',
|
||||||
},
|
},
|
||||||
@@ -234,6 +242,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
|||||||
total_net: (run as { total_net: number }).total_net,
|
total_net: (run as { total_net: number }).total_net,
|
||||||
total_avgifter: (run as { total_avgifter: number }).total_avgifter,
|
total_avgifter: (run as { total_avgifter: number }).total_avgifter,
|
||||||
total_vacation_accrual: (run as { total_vacation_accrual: number }).total_vacation_accrual,
|
total_vacation_accrual: (run as { total_vacation_accrual: number }).total_vacation_accrual,
|
||||||
|
calculation_params: (run as { calculation_params: Record<string, unknown> | null }).calculation_params,
|
||||||
employees: (employees as EmpRow[]).map((sre) => ({
|
employees: (employees as EmpRow[]).map((sre) => ({
|
||||||
employee_id: sre.employee_id,
|
employee_id: sre.employee_id,
|
||||||
employment_type: sre.employee?.employment_type || 'employee',
|
employment_type: sre.employee?.employment_type || 'employee',
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const makeRun = (overrides: Record<string, unknown> = {}) => ({
|
|||||||
total_net: 23000,
|
total_net: 23000,
|
||||||
total_avgifter: 9426,
|
total_avgifter: 9426,
|
||||||
total_vacation_accrual: 0,
|
total_vacation_accrual: 0,
|
||||||
|
calculation_params: { slpRate: 0.2426 },
|
||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -194,5 +195,11 @@ describe('bookPaidSalaryRun', () => {
|
|||||||
expect(result.ok).toBe(true)
|
expect(result.ok).toBe(true)
|
||||||
if (result.ok) expect(result.data.entryIds).toEqual(['je-1', 'je-2'])
|
if (result.ok) expect(result.data.entryIds).toEqual(['je-1', 'je-2'])
|
||||||
expect(createSalaryRunEntries).toHaveBeenCalledTimes(1)
|
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_net: employees.reduce((s, e) => s + e.net_salary, 0),
|
||||||
total_avgifter: employees.reduce((s, e) => s + e.avgifter_amount, 0),
|
total_avgifter: employees.reduce((s, e) => s + e.avgifter_amount, 0),
|
||||||
total_vacation_accrual: employees.reduce((s, e) => s + e.vacation_accrual, 0),
|
total_vacation_accrual: employees.reduce((s, e) => s + e.vacation_accrual, 0),
|
||||||
|
calculation_params: { slpRate: 0.2426 },
|
||||||
employees,
|
employees,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,6 +277,36 @@ describe('salary entries: dimensions propagation (PR8)', () => {
|
|||||||
assertBalanced(pension)
|
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 () => {
|
it('rejects an invalid bag (coerce gate) rather than booking junk keys', async () => {
|
||||||
const run = makeRun([
|
const run = makeRun([
|
||||||
makeEmployee({ employee_id: 'a', default_dimensions: { '0': 'BAD' } as Record<string, string> }),
|
makeEmployee({ employee_id: 'a', default_dimensions: { '0': 'BAD' } as Record<string, string> }),
|
||||||
|
|||||||
@@ -151,6 +151,9 @@ async function bookLoadedRun(
|
|||||||
total_net: run.total_net as number,
|
total_net: run.total_net as number,
|
||||||
total_avgifter: run.total_avgifter as number,
|
total_avgifter: run.total_avgifter as number,
|
||||||
total_vacation_accrual: run.total_vacation_accrual 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) => ({
|
employees: roster.map((sre) => ({
|
||||||
employee_id: sre.employee_id,
|
employee_id: sre.employee_id,
|
||||||
employment_type: sre.employee?.employment_type || 'employee',
|
employment_type: sre.employee?.employment_type || 'employee',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { roundOre } from '@/lib/money'
|
||||||
import type { PayrollConfig } from './payroll-config'
|
import type { PayrollConfig } from './payroll-config'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +32,34 @@ export interface LoneVaxlingStep {
|
|||||||
output: number
|
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.
|
* Calculate löneväxling impact.
|
||||||
*
|
*
|
||||||
@@ -50,7 +79,10 @@ export function calculateLoneVaxling(
|
|||||||
const warnings: string[] = []
|
const warnings: string[] = []
|
||||||
|
|
||||||
const factor = 1.058
|
const factor = 1.058
|
||||||
const pensionContribution = r(reductionAmount * factor)
|
const { pensionContribution, slpOnPension } = calculateLoneVaxlingPensionProvision(
|
||||||
|
reductionAmount,
|
||||||
|
config.slpRate,
|
||||||
|
)
|
||||||
steps.push({
|
steps.push({
|
||||||
label: 'Pensionsavsättning',
|
label: 'Pensionsavsättning',
|
||||||
formula: 'reduction × 1.058',
|
formula: 'reduction × 1.058',
|
||||||
@@ -76,7 +108,6 @@ export function calculateLoneVaxling(
|
|||||||
})
|
})
|
||||||
|
|
||||||
// SLP (Särskild löneskatt) on pension: 24.26%
|
// SLP (Särskild löneskatt) on pension: 24.26%
|
||||||
const slpOnPension = r(pensionContribution * config.slpRate)
|
|
||||||
steps.push({
|
steps.push({
|
||||||
label: 'Särskild löneskatt på pension',
|
label: 'Särskild löneskatt på pension',
|
||||||
formula: 'pension × SLP_rate',
|
formula: 'pension × SLP_rate',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { createLogger } from '@/lib/logger'
|
import { createLogger } from '@/lib/logger'
|
||||||
import { roundOre } from '@/lib/money'
|
import { roundOre } from '@/lib/money'
|
||||||
import { SALARY_ACCOUNTS, getLineItemAccount } from './account-mapping'
|
import { SALARY_ACCOUNTS, getLineItemAccount } from './account-mapping'
|
||||||
|
import { calculateLoneVaxlingPensionProvision } from './lonevaxling'
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
import type {
|
import type {
|
||||||
CreateJournalEntryInput,
|
CreateJournalEntryInput,
|
||||||
@@ -58,9 +59,47 @@ interface SalaryRunData {
|
|||||||
total_net: number
|
total_net: number
|
||||||
total_avgifter: number
|
total_avgifter: number
|
||||||
total_vacation_accrual: number
|
total_vacation_accrual: number
|
||||||
|
calculation_params?: Record<string, unknown> | null
|
||||||
employees: SalaryRunEmployee[]
|
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.
|
* Create all journal entries for a salary run.
|
||||||
* Creates 3 entries:
|
* Creates 3 entries:
|
||||||
@@ -81,6 +120,7 @@ export async function createSalaryRunEntries(
|
|||||||
vacationEntry: JournalEntry | null
|
vacationEntry: JournalEntry | null
|
||||||
pensionEntry: JournalEntry | null
|
pensionEntry: JournalEntry | null
|
||||||
}> {
|
}> {
|
||||||
|
const postingRun = resolveLoneVaxlingPension(run)
|
||||||
const entryDate = run.payment_date
|
const entryDate = run.payment_date
|
||||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
|
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
|
||||||
if (!fiscalPeriodId) {
|
if (!fiscalPeriodId) {
|
||||||
@@ -90,25 +130,25 @@ export async function createSalaryRunEntries(
|
|||||||
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
|
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
|
||||||
const desc = `Lön ${periodLabel}`
|
const desc = `Lön ${periodLabel}`
|
||||||
|
|
||||||
await ensureSalaryAccountsExist(supabase, companyId, userId, run)
|
await ensureSalaryAccountsExist(supabase, companyId, userId, postingRun)
|
||||||
|
|
||||||
// ─── Entry 1: Salary (brutto, skatt, netto) ───
|
// ─── Entry 1: Salary (brutto, skatt, netto) ───
|
||||||
const salaryEntry = await createSalaryEntry(
|
const salaryEntry = await createSalaryEntry(
|
||||||
supabase, companyId, userId, run, fiscalPeriodId, desc
|
supabase, companyId, userId, postingRun, fiscalPeriodId, desc
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Entry 2: Arbetsgivaravgifter ───
|
// ─── Entry 2: Arbetsgivaravgifter ───
|
||||||
const avgifterEntry = await createAvgifterEntry(
|
const avgifterEntry = await createAvgifterEntry(
|
||||||
supabase, companyId, userId, run, fiscalPeriodId, desc
|
supabase, companyId, userId, postingRun, fiscalPeriodId, desc
|
||||||
)
|
)
|
||||||
|
|
||||||
// ─── Entry 3: Vacation accrual (if any) ───
|
// ─── Entry 3: Vacation accrual (if any) ───
|
||||||
let vacationEntry: JournalEntry | null = null
|
let vacationEntry: JournalEntry | null = null
|
||||||
const totalVacation = run.employees.reduce((sum, e) => sum + e.vacation_accrual, 0)
|
const totalVacation = postingRun.employees.reduce((sum, e) => sum + e.vacation_accrual, 0)
|
||||||
const totalVacationAvgifter = run.employees.reduce((sum, e) => sum + e.vacation_accrual_avgifter, 0)
|
const totalVacationAvgifter = postingRun.employees.reduce((sum, e) => sum + e.vacation_accrual_avgifter, 0)
|
||||||
if (totalVacation > 0 || totalVacationAvgifter > 0) {
|
if (totalVacation > 0 || totalVacationAvgifter > 0) {
|
||||||
vacationEntry = await createVacationEntry(
|
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 7410 Pensionsförsäkringspremier / Credit 2740 Skuld pensionsförsäkringar
|
||||||
// Debit 7533 Särskild löneskatt / Credit 2514 Beräknad särskild löneskatt
|
// Debit 7533 Särskild löneskatt / Credit 2514 Beräknad särskild löneskatt
|
||||||
let pensionEntry: JournalEntry | null = null
|
let pensionEntry: JournalEntry | null = null
|
||||||
const totalPension = run.employees.reduce((sum, e) => sum + (e.pension_contribution || 0), 0)
|
const totalPension = postingRun.employees.reduce((sum, e) => sum + (e.pension_contribution || 0), 0)
|
||||||
const totalSlp = run.employees.reduce((sum, e) => sum + (e.pension_slp || 0), 0)
|
const totalSlp = postingRun.employees.reduce((sum, e) => sum + (e.pension_slp || 0), 0)
|
||||||
if (totalPension > 0) {
|
if (totalPension > 0) {
|
||||||
pensionEntry = await createPensionEntry(
|
pensionEntry = await createPensionEntry(
|
||||||
supabase, companyId, userId, run, fiscalPeriodId, desc, totalPension, totalSlp
|
supabase, companyId, userId, postingRun, fiscalPeriodId, desc, totalPension, totalSlp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user