From c9625fa45ce2da90a3bbb7228da32f2802e37bbf Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:26:49 +0200 Subject: [PATCH] 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 --- DECISIONS.md | 2 + .../[id]/__tests__/lifecycle.test.ts | 7 +++ .../salary-runs/[id]/book/route.ts | 13 ++++- lib/salary/__tests__/book-run.test.ts | 7 +++ lib/salary/__tests__/salary-entries.test.ts | 31 ++++++++++ lib/salary/book-run.ts | 3 + lib/salary/lonevaxling.ts | 35 ++++++++++- lib/salary/salary-entries.ts | 58 ++++++++++++++++--- 8 files changed, 143 insertions(+), 13 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 96b4503e..9552a955 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2,6 +2,8 @@ One line per decision: `[YYYY-MM-DD] : `. 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] 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. diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts index 4faea8ed..70ae9f96 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts @@ -421,6 +421,7 @@ describe('POST /salary-runs/:id/book', () => { total_net: 25500, total_avgifter: 10997, total_vacation_accrual: 0, + calculation_params: { slpRate: 0.2426 }, } const employeeRow = { @@ -478,6 +479,12 @@ describe('POST /salary-runs/:id/book', () => { expect(body.data.entry_ids).toEqual(['je_salary', 'je_avg']) expect(body.meta.audit.voucher_number).toBe('L2026-0023') 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 () => { diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts index 5ca20dcb..a106e935 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts @@ -190,8 +190,16 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string 'salary (gross + tax withholding + net payment)', 'arbetsgivaravgifter', ...(totalVacation > 0 || totalVacationAvgifter > 0 ? ['vacation accrual'] : []), - // Pension cannot be detected without the engine: we'd need - // to inspect line_items for löneväxling. Omitted from preview. + ...((employees as Array<{ line_items: Array<{ item_type: string; amount: number }> | null }>).some( + (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.', }, @@ -234,6 +242,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string total_net: (run as { total_net: number }).total_net, total_avgifter: (run as { total_avgifter: number }).total_avgifter, total_vacation_accrual: (run as { total_vacation_accrual: number }).total_vacation_accrual, + calculation_params: (run as { calculation_params: Record | null }).calculation_params, employees: (employees as EmpRow[]).map((sre) => ({ employee_id: sre.employee_id, employment_type: sre.employee?.employment_type || 'employee', diff --git a/lib/salary/__tests__/book-run.test.ts b/lib/salary/__tests__/book-run.test.ts index 082ad37c..973998b5 100644 --- a/lib/salary/__tests__/book-run.test.ts +++ b/lib/salary/__tests__/book-run.test.ts @@ -41,6 +41,7 @@ const makeRun = (overrides: Record = {}) => ({ 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 } }), + ) }) }) diff --git a/lib/salary/__tests__/salary-entries.test.ts b/lib/salary/__tests__/salary-entries.test.ts index 79fabfc6..96d5eade 100644 --- a/lib/salary/__tests__/salary-entries.test.ts +++ b/lib/salary/__tests__/salary-entries.test.ts @@ -83,6 +83,7 @@ function makeRun(employees: ReturnType[]) { 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 }), diff --git a/lib/salary/book-run.ts b/lib/salary/book-run.ts index d7d8bf30..9da8a317 100644 --- a/lib/salary/book-run.ts +++ b/lib/salary/book-run.ts @@ -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 | null, employees: roster.map((sre) => ({ employee_id: sre.employee_id, employment_type: sre.employee?.employment_type || 'employee', diff --git a/lib/salary/lonevaxling.ts b/lib/salary/lonevaxling.ts index d26a1748..b33b881c 100644 --- a/lib/salary/lonevaxling.ts +++ b/lib/salary/lonevaxling.ts @@ -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', diff --git a/lib/salary/salary-entries.ts b/lib/salary/salary-entries.ts index 51acaca7..ccc5ebfe 100644 --- a/lib/salary/salary-entries.ts +++ b/lib/salary/salary-entries.ts @@ -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 | 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 ) }