From 6c54f80a936c7177e7c48d6a276930c38f6e67b0 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 13 May 2026 19:15:31 +0200 Subject: [PATCH] Bug/agi vacation submission (#468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(vacation): add semesterersättning option for direct vacation compensation * feat(vacation): enhance vacation rule tests for accurate salary calculations * feat(vacation): add semesterersättning rule for direct vacation compensation * feat(vacation): update vacation rule handling and add calculateVacationAccrual tests --- .../salary/employees/[id]/page.tsx | 6 + app/(dashboard)/salary/employees/new/page.tsx | 6 + .../runs/[id]/employees/[employeeId]/page.tsx | 1 + app/api/salary/runs/[id]/calculate/route.ts | 35 ++ lib/api/schemas.ts | 2 +- lib/reports/vacation-liability.ts | 5 +- .../__tests__/calculation-engine.test.ts | 510 +++++++++++++++++- lib/salary/account-mapping.ts | 2 + lib/salary/calculation-engine.ts | 83 ++- ...260513160559_salary_semesterersattning.sql | 39 ++ types/index.ts | 4 +- 11 files changed, 676 insertions(+), 17 deletions(-) create mode 100644 supabase/migrations/20260513160559_salary_semesterersattning.sql diff --git a/app/(dashboard)/salary/employees/[id]/page.tsx b/app/(dashboard)/salary/employees/[id]/page.tsx index bcf3c441..9c0b110f 100644 --- a/app/(dashboard)/salary/employees/[id]/page.tsx +++ b/app/(dashboard)/salary/employees/[id]/page.tsx @@ -378,6 +378,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s Procentregeln (12 %) Sammalöneregeln + Semesterersättning (betalas ut direkt) Ingen semesteravsättning @@ -386,6 +387,11 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s Ingen avsättning till 2920 bokas. Använd om semester ingår i månadslönen — vanligt för ägare som är enda anställd.

)} + {vacationRule === 'semesterersattning' && ( +

+ 12 % läggs på varje lönekörning och bokas mot 7285. Ingen semesterlöneskuld byggs upp — vanligt för tim- och visstidsanställda. +

+ )}
diff --git a/app/(dashboard)/salary/employees/new/page.tsx b/app/(dashboard)/salary/employees/new/page.tsx index 7f0da56c..f1f1e8d2 100644 --- a/app/(dashboard)/salary/employees/new/page.tsx +++ b/app/(dashboard)/salary/employees/new/page.tsx @@ -293,6 +293,7 @@ export default function NewEmployeePage() { Procentregeln (12 %) Sammalöneregeln + Semesterersättning (betalas ut direkt) Ingen semesteravsättning @@ -301,6 +302,11 @@ export default function NewEmployeePage() { Ingen avsättning till 2920 bokas. Vanligt för ägare som är enda anställd.

)} + {vacationRule === 'semesterersattning' && ( +

+ 12 % läggs på varje lönekörning och bokas mot 7285. Ingen semesterlöneskuld byggs upp — vanligt för tim- och visstidsanställda. +

+ )}
diff --git a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx index c70aefb3..758ff6af 100644 --- a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx @@ -29,6 +29,7 @@ const LINE_ITEM_TYPE_LABELS: Record = { vab: 'VAB (vård av sjukt barn)', parental_leave: 'Föräldraledighet', vacation: 'Semester', + semesterersattning: 'Semesterersättning', traktamente_taxfree: 'Traktamente (skattefritt)', traktamente_taxable: 'Traktamente (skattepliktigt)', mileage_taxfree: 'Milersättning (skattefritt)', diff --git a/app/api/salary/runs/[id]/calculate/route.ts b/app/api/salary/runs/[id]/calculate/route.ts index f202109e..8f47ee26 100644 --- a/app/api/salary/runs/[id]/calculate/route.ts +++ b/app/api/salary/runs/[id]/calculate/route.ts @@ -334,6 +334,8 @@ export const POST = withRouteContext( .filter((li: Record) => { if (DERIVED_ABSENCE_TYPES.includes(li.item_type as SalaryLineItemType)) return false if (li.source_benefit_id) return false + // Semesterersättning is derived by the engine on every calculate. + if (li.item_type === 'semesterersattning') return false return true }) .map((li: Record) => ({ @@ -460,6 +462,39 @@ export const POST = withRouteContext( return errorResponse(empUpdateError, opLog, { requestId }) } + // Persist a derived 'semesterersattning' line item so it appears in the + // lönerader UI, on the payslip, and books to BAS 7285 via the line-item + // flow in salary-entries.ts. Replace any prior derived row first. + const { error: delSemErr } = await supabase + .from('salary_line_items') + .delete() + .eq('salary_run_employee_id', sre.id) + .eq('item_type', 'semesterersattning') + if (delSemErr) { + return errorResponse(delSemErr, opLog, { requestId }) + } + if (result.vacationCompensation > 0) { + const { error: insSemErr } = await supabase.from('salary_line_items').insert({ + salary_run_employee_id: sre.id, + company_id: companyId, + item_type: 'semesterersattning', + description: 'Semesterersättning', + quantity: 1, + amount: Math.round(result.vacationCompensation * 100) / 100, + is_taxable: true, + is_avgift_basis: true, + // Not vacation basis itself — would create circular accrual. + is_vacation_basis: false, + is_gross_deduction: false, + is_net_deduction: false, + account_number: getLineItemAccount('semesterersattning', emp.employment_type), + sort_order: 50, + }) + if (insSemErr) { + return errorResponse(insSemErr, opLog, { requestId }) + } + } + totalGross += result.grossSalary totalTax += result.taxWithheld totalNet += result.netSalary diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index f79c09d1..c8c4da0f 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -681,7 +681,7 @@ export const SupplierImportExecuteSchema = z.object({ export const EmploymentTypeSchema = z.enum(['employee', 'company_owner', 'board_member']) export const SalaryTypeSchema = z.enum(['monthly', 'hourly']) export const FSkattStatusSchema = z.enum(['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified']) -export const VacationRuleSchema = z.enum(['procentregeln', 'sammaloneregeln', 'none']) +export const VacationRuleSchema = z.enum(['procentregeln', 'sammaloneregeln', 'none', 'semesterersattning']) export const SalaryRunStatusSchema = z.enum(['draft', 'review', 'approved', 'paid', 'booked', 'corrected']) export const SalaryLineItemTypeSchema = z.enum([ diff --git a/lib/reports/vacation-liability.ts b/lib/reports/vacation-liability.ts index 893895a2..56a7a2fb 100644 --- a/lib/reports/vacation-liability.ts +++ b/lib/reports/vacation-liability.ts @@ -50,13 +50,16 @@ export async function generateVacationLiability( ): Promise { const r = (x: number) => Math.round(x * 100) / 100 - // Load active employees + // Load active employees who actually accrue vacation. Employees on + // 'none' or 'semesterersattning' have no semesterlöneskuld liability — + // including them in the report would just show empty rows. const employees = await fetchAllRows(({ from, to }) => supabase .from('employees') .select('id, first_name, last_name, personnummer_last4, vacation_rule, vacation_days_per_year, vacation_days_saved') .eq('company_id', companyId) .eq('is_active', true) + .not('vacation_rule', 'in', '(none,semesterersattning)') .order('last_name') .range(from, to) ) diff --git a/lib/salary/__tests__/calculation-engine.test.ts b/lib/salary/__tests__/calculation-engine.test.ts index cf460efd..51f8849c 100644 --- a/lib/salary/__tests__/calculation-engine.test.ts +++ b/lib/salary/__tests__/calculation-engine.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { calculateSalary, calculateKarensavdrag, calculateSjuklon, calculateAvgifterRate } from '../calculation-engine' +import { calculateSalary, calculateKarensavdrag, calculateSjuklon, calculateAvgifterRate, calculateVacationAccrual } from '../calculation-engine' import type { PayrollConfig } from '../payroll-config' import type { TaxTableRate } from '../tax-tables' @@ -261,6 +261,99 @@ describe('calculateSalary', () => { expect(result.vacationAccrual).toBe(Math.round(40000 * 0.144 * 100) / 100) }) + it('pays semesterersättning directly when vacation_rule = semesterersattning', () => { + const result = calculateSalary( + makeBasicInput({ vacationRule: 'semesterersattning', vacationDaysPerYear: 25 }), + config2026, + emptyTaxRates + ) + + const expectedCompensation = Math.round(40000 * 0.12 * 100) / 100 + expect(result.vacationCompensation).toBe(expectedCompensation) + expect(result.grossSalary).toBe(40000 + expectedCompensation) + expect(result.vacationAccrual).toBe(0) + expect(result.vacationAccrualAvgifter).toBe(0) + // Avgifter basis includes the compensation + expect(result.avgifterBasis).toBe(40000 + expectedCompensation) + }) + + it('uses 14.4% for semesterersättning with 30+ vacation days', () => { + const result = calculateSalary( + makeBasicInput({ vacationRule: 'semesterersattning', vacationDaysPerYear: 30 }), + config2026, + emptyTaxRates + ) + + expect(result.vacationCompensation).toBe(Math.round(40000 * 0.144 * 100) / 100) + expect(result.vacationAccrual).toBe(0) + }) + + it('semesterersättning includes vacation-basis line items (e.g. overtime)', () => { + const result = calculateSalary( + makeBasicInput({ + vacationRule: 'semesterersattning', + vacationDaysPerYear: 25, + lineItems: [ + { itemType: 'overtime', amount: 5000, isTaxable: true, isAvgiftBasis: true, isVacationBasis: true, isGrossDeduction: false, isNetDeduction: false }, + ], + }), + config2026, + emptyTaxRates + ) + + // 12% × (40000 base + 5000 overtime) = 5400 + expect(result.vacationCompensation).toBe(5400) + // 40000 base + 5000 overtime + 5400 semesterersättning + expect(result.grossSalary).toBe(50400) + }) + + it('does not double-count base salary line items in vacation basis', () => { + // The API auto-creates a monthly_salary line item with amount = baseSalary + // and is_vacation_basis: true. The engine must not add it on top of its + // own baseSalary computation — otherwise procentregeln/semesterersättning + // would compute 2× the correct amount. + const monthlyResult = calculateSalary( + makeBasicInput({ + monthlySalary: 40000, + employmentDegree: 50, // grundlön = 20000 + vacationRule: 'procentregeln', + lineItems: [ + { itemType: 'monthly_salary', amount: 20000, isTaxable: true, isAvgiftBasis: true, isVacationBasis: true, isGrossDeduction: false, isNetDeduction: false }, + ], + }), + config2026, + emptyTaxRates + ) + expect(monthlyResult.vacationAccrual).toBe(2400) // 12% × 20000, not 12% × 40000 + + const compResult = calculateSalary( + makeBasicInput({ + monthlySalary: 40000, + employmentDegree: 50, + vacationRule: 'semesterersattning', + lineItems: [ + { itemType: 'monthly_salary', amount: 20000, isTaxable: true, isAvgiftBasis: true, isVacationBasis: true, isGrossDeduction: false, isNetDeduction: false }, + ], + }), + config2026, + emptyTaxRates + ) + expect(compResult.vacationCompensation).toBe(2400) + expect(compResult.grossSalary).toBe(22400) // 20000 base + 2400 comp + }) + + it('skips accrual when vacation_rule = none', () => { + const result = calculateSalary( + makeBasicInput({ vacationRule: 'none' }), + config2026, + emptyTaxRates + ) + + expect(result.vacationAccrual).toBe(0) + expect(result.vacationCompensation).toBe(0) + expect(result.grossSalary).toBe(40000) + }) + it('adds benefit values to tax base but not gross', () => { const result = calculateSalary( makeBasicInput({ @@ -291,6 +384,421 @@ describe('calculateSalary', () => { }) }) +// ============================================================ +// Hardening — realistic API flow & cross-rule invariants +// ============================================================ +// +// These tests mirror the actual production input shape, where the +// "Lägg till anställd" endpoint auto-creates a base salary line item +// (monthly_salary or hourly_salary) before the engine runs. The plain +// `makeBasicInput()` cases above don't include that line item, so they +// miss the double-counting class of bug. The helper below adds it. + +type LineItem = { + itemType: string + amount: number + isTaxable: boolean + isAvgiftBasis: boolean + isVacationBasis: boolean + isGrossDeduction: boolean + isNetDeduction: boolean +} + +function baseLineItem(amount: number, type: 'monthly_salary' | 'hourly_salary' = 'monthly_salary'): LineItem { + return { + itemType: type, + amount, + isTaxable: true, + isAvgiftBasis: true, + isVacationBasis: true, + isGrossDeduction: false, + isNetDeduction: false, + } +} + +function lineItem(overrides: Partial & { itemType: string; amount: number }): LineItem { + return { + isTaxable: true, + isAvgiftBasis: true, + isVacationBasis: false, + isGrossDeduction: false, + isNetDeduction: false, + ...overrides, + } +} + +describe('hardening — vacation rule contract', () => { + // Same input across all 4 vacation rules, locking in the relationships + // between them. This is the contract a Swedish accountant relies on. + const base = { + monthlySalary: 40000, + employmentDegree: 100, + vacationDaysPerYear: 25, + semestertillaggRate: 0.0043, + lineItems: [baseLineItem(40000)], + } + + it('procentregeln: 12% accrued, 0 paid out', () => { + const r = calculateSalary( + makeBasicInput({ ...base, vacationRule: 'procentregeln' }), + config2026, emptyTaxRates + ) + expect(r.grossSalary).toBe(40000) + expect(r.vacationCompensation).toBe(0) + expect(r.vacationAccrual).toBe(4800) + }) + + it('semesterersattning: 0 accrued, 12% paid out into gross', () => { + const r = calculateSalary( + makeBasicInput({ ...base, vacationRule: 'semesterersattning' }), + config2026, emptyTaxRates + ) + expect(r.grossSalary).toBe(44800) // 40000 + 4800 + expect(r.vacationCompensation).toBe(4800) + expect(r.vacationAccrual).toBe(0) + }) + + it('sammaloneregeln: only tillägg accrued, 0 paid out', () => { + const r = calculateSalary( + makeBasicInput({ ...base, vacationRule: 'sammaloneregeln' }), + config2026, emptyTaxRates + ) + expect(r.grossSalary).toBe(40000) + expect(r.vacationCompensation).toBe(0) + // dagslön 40000/21 × 0.43% × 25 ≈ 204.76 + expect(r.vacationAccrual).toBeCloseTo(204.76, 1) + }) + + it('none: nothing accrued, nothing paid out', () => { + const r = calculateSalary( + makeBasicInput({ ...base, vacationRule: 'none' }), + config2026, emptyTaxRates + ) + expect(r.grossSalary).toBe(40000) + expect(r.vacationCompensation).toBe(0) + expect(r.vacationAccrual).toBe(0) + expect(r.vacationAccrualAvgifter).toBe(0) + }) + + it('procentregeln and semesterersattning produce identical totalEmployerCost', () => { + // Different bookkeeping (2920 accrual vs 7285 cash) but the same total + // cost to the company. Locking this is the cheapest sanity check. + const proc = calculateSalary( + makeBasicInput({ ...base, vacationRule: 'procentregeln' }), + config2026, emptyTaxRates + ) + const semer = calculateSalary( + makeBasicInput({ ...base, vacationRule: 'semesterersattning' }), + config2026, emptyTaxRates + ) + expect(semer.totalEmployerCost).toBeCloseTo(proc.totalEmployerCost, 1) + }) +}) + +describe('hardening — boundaries', () => { + it('vacation_days_per_year = 29 uses 12%', () => { + const r = calculateSalary( + makeBasicInput({ vacationRule: 'procentregeln', vacationDaysPerYear: 29 }), + config2026, emptyTaxRates + ) + expect(r.vacationAccrual).toBeCloseTo(40000 * 0.12, 1) + }) + + it('vacation_days_per_year = 30 uses 14.4%', () => { + const r = calculateSalary( + makeBasicInput({ vacationRule: 'procentregeln', vacationDaysPerYear: 30 }), + config2026, emptyTaxRates + ) + expect(r.vacationAccrual).toBeCloseTo(40000 * 0.144, 1) + }) + + it('semesterersättning at 29 vs 30 days flips rate', () => { + const r29 = calculateSalary( + makeBasicInput({ vacationRule: 'semesterersattning', vacationDaysPerYear: 29 }), + config2026, emptyTaxRates + ) + const r30 = calculateSalary( + makeBasicInput({ vacationRule: 'semesterersattning', vacationDaysPerYear: 30 }), + config2026, emptyTaxRates + ) + expect(r29.vacationCompensation).toBeCloseTo(40000 * 0.12, 1) + expect(r30.vacationCompensation).toBeCloseTo(40000 * 0.144, 1) + }) + + it('jämkning expires on validTo (inclusive)', () => { + const r = calculateSalary( + makeBasicInput({ + jamkningPercentage: 10, + jamkningValidFrom: '2026-01-01', + jamkningValidTo: '2026-04-30', + paymentDate: '2026-04-30', + }), + config2026, emptyTaxRates + ) + expect(r.taxWithheld).toBe(4000) // 10% × 40000 — still valid on the last day + }) + + it('jämkning not applied the day after validTo', () => { + const r = calculateSalary( + makeBasicInput({ + jamkningPercentage: 10, + jamkningValidFrom: '2026-01-01', + jamkningValidTo: '2026-04-30', + paymentDate: '2026-05-01', + }), + config2026, emptyTaxRates + ) + expect(r.taxWithheld).toBe(12000) // fallback 30% + }) +}) + +describe('hardening — sammalöneregeln', () => { + it('part-time worker gets half the tillägg of a full-timer', () => { + const full = calculateSalary( + makeBasicInput({ + vacationRule: 'sammaloneregeln', monthlySalary: 40000, employmentDegree: 100, + lineItems: [baseLineItem(40000)], + }), + config2026, emptyTaxRates + ) + const half = calculateSalary( + makeBasicInput({ + vacationRule: 'sammaloneregeln', monthlySalary: 40000, employmentDegree: 50, + lineItems: [baseLineItem(20000)], + }), + config2026, emptyTaxRates + ) + expect(half.vacationAccrual).toBeCloseTo(full.vacationAccrual / 2, 1) + }) + + it('respects custom semestertillagg_rate (CBA 0.8%)', () => { + const r = calculateSalary( + makeBasicInput({ + vacationRule: 'sammaloneregeln', semestertillaggRate: 0.008, + lineItems: [baseLineItem(40000)], + }), + config2026, emptyTaxRates + ) + // 40000/21 × 0.8% × 25 ≈ 380.95 + expect(r.vacationAccrual).toBeCloseTo(380.95, 1) + }) +}) + +describe('hardening — realistic combined scenarios', () => { + it('monthly worker with overtime, benefit, pension deduction, semesterersättning', () => { + const r = calculateSalary( + makeBasicInput({ + vacationRule: 'semesterersattning', + vacationDaysPerYear: 25, + lineItems: [ + baseLineItem(40000), + lineItem({ itemType: 'overtime', amount: 5000, isVacationBasis: true }), + lineItem({ itemType: 'benefit_car', amount: 3000 }), // not vacation basis + lineItem({ itemType: 'gross_deduction_pension', amount: -2000, isGrossDeduction: true, isVacationBasis: false }), + ], + }), + config2026, emptyTaxRates + ) + + // Vacation basis = 40000 base + 5000 overtime (benefits excluded, gross deduction excluded) + // Semesterersättning = 12% × 45000 = 5400 + expect(r.vacationCompensation).toBe(5400) + // Gross = 40000 + 5000 overtime + 5400 semer − 2000 pension = 48400 + expect(r.grossSalary).toBe(48400) + // Tax base = gross + benefits = 48400 + 3000 = 51400 + expect(r.taxableIncome).toBe(51400) + expect(r.taxWithheld).toBe(15420) // 30% fallback + // Avgifter basis = gross + benefits = 51400 + expect(r.avgifterBasis).toBe(51400) + expect(r.vacationAccrual).toBe(0) + }) + + it('hourly worker with no manual line items still gets correct semesterersättning', () => { + // Production sends an hourly_salary line item with the computed amount. + const r = calculateSalary( + makeBasicInput({ + salaryType: 'hourly', + monthlySalary: 0, + hourlyRate: 250, + hoursWorked: 160, + vacationRule: 'semesterersattning', + lineItems: [baseLineItem(40000, 'hourly_salary')], + }), + config2026, emptyTaxRates + ) + expect(r.vacationCompensation).toBe(4800) // 12% × 40000, not 12% × 80000 + expect(r.grossSalary).toBe(44800) + }) + + it('benefits do not inflate semesterersättning basis', () => { + // Förmånsvärden are NOT vacation basis (employee doesn't earn vacation + // pay on the value of their company car). The engine must exclude them. + const r = calculateSalary( + makeBasicInput({ + vacationRule: 'semesterersattning', + lineItems: [ + baseLineItem(40000), + lineItem({ itemType: 'benefit_car', amount: 10000, isVacationBasis: false }), + ], + }), + config2026, emptyTaxRates + ) + // Should be 12% × 40000 = 4800, not 12% × 50000 = 6000 + expect(r.vacationCompensation).toBe(4800) + }) +}) + +describe('hardening — invariants', () => { + it('netSalary + taxWithheld + netDeductions = grossSalary (always)', () => { + for (const rule of ['procentregeln', 'sammaloneregeln', 'none', 'semesterersattning'] as const) { + const r = calculateSalary( + makeBasicInput({ + vacationRule: rule, + lineItems: [ + baseLineItem(40000), + lineItem({ itemType: 'net_deduction_advance', amount: -2000, isNetDeduction: true, isTaxable: false, isAvgiftBasis: false }), + ], + }), + config2026, emptyTaxRates + ) + const reconstructed = Math.round((r.netSalary + r.taxWithheld + r.netDeductions) * 100) / 100 + expect(reconstructed).toBeCloseTo(r.grossSalary, 1) + } + }) + + it('totalEmployerCost = grossSalary + avgifterAmount + vacationAccrual + vacationAccrualAvgifter', () => { + for (const rule of ['procentregeln', 'sammaloneregeln', 'none', 'semesterersattning'] as const) { + const r = calculateSalary( + makeBasicInput({ vacationRule: rule, lineItems: [baseLineItem(40000)] }), + config2026, emptyTaxRates + ) + const expected = Math.round((r.grossSalary + r.avgifterAmount + r.vacationAccrual + r.vacationAccrualAvgifter) * 100) / 100 + expect(expected).toBeCloseTo(r.totalEmployerCost, 1) + } + }) + + it('avgifterAmount = avgifterBasis × avgifterRate (within rounding)', () => { + const r = calculateSalary( + makeBasicInput({ lineItems: [baseLineItem(40000)] }), + config2026, emptyTaxRates + ) + expect(r.avgifterAmount).toBeCloseTo(r.avgifterBasis * r.avgifterRate, 1) + }) + + it('semesterersattning never produces both vacationAccrual and vacationCompensation', () => { + const r = calculateSalary( + makeBasicInput({ + vacationRule: 'semesterersattning', + lineItems: [baseLineItem(40000)], + }), + config2026, emptyTaxRates + ) + expect(r.vacationAccrual).toBe(0) + expect(r.vacationCompensation).toBeGreaterThan(0) + }) +}) + +describe('hardening — tax table lookup (not just flat fallback)', () => { + const taxRates: TaxTableRate[] = [ + { tableYear: 2026, tableNumber: 32, columnNumber: 1, incomeFrom: 0, incomeTo: 20000, taxAmount: 3000 }, + { tableYear: 2026, tableNumber: 32, columnNumber: 1, incomeFrom: 20001, incomeTo: 30000, taxAmount: 5500 }, + { tableYear: 2026, tableNumber: 32, columnNumber: 1, incomeFrom: 30001, incomeTo: 50000, taxAmount: 10000 }, + ] + + it('uses table lookup when taxTableNumber is set', () => { + const r = calculateSalary( + makeBasicInput({ taxTableNumber: 32, taxColumn: 1, monthlySalary: 25000, lineItems: [baseLineItem(25000)] }), + config2026, taxRates + ) + expect(r.taxWithheld).toBe(5500) + }) + + it('semesterersättning pushes brutto into a higher tax bracket', () => { + // Base 25000 + 12% semer = 28000, still in 20001-30000 bracket + const semer = calculateSalary( + makeBasicInput({ + taxTableNumber: 32, taxColumn: 1, monthlySalary: 25000, + vacationRule: 'semesterersattning', + lineItems: [baseLineItem(25000)], + }), + config2026, taxRates + ) + expect(semer.grossSalary).toBe(28000) + expect(semer.taxWithheld).toBe(5500) // still bracket 2 + }) +}) + +describe('calculateVacationAccrual (standalone export)', () => { + it('procentregeln: 12% of vacationBasis under 30 days', () => { + const r = calculateVacationAccrual({ + monthlySalary: 40000, + vacationRule: 'procentregeln', + vacationDaysPerYear: 25, + semestertillaggRate: 0.0043, + vacationBasis: 40000, + }) + expect(r.accrual).toBe(4800) + }) + + it('procentregeln: 14.4% at 30+ days', () => { + const r = calculateVacationAccrual({ + monthlySalary: 40000, + vacationRule: 'procentregeln', + vacationDaysPerYear: 30, + semestertillaggRate: 0.0043, + vacationBasis: 40000, + }) + expect(r.accrual).toBeCloseTo(5760, 1) + }) + + it('sammaloneregeln: uses vacationBasis, not monthlySalary (degree-adjusted)', () => { + // 50% worker — caller passes vacationBasis = 20000 (degree-adjusted), + // not the raw 40000 monthlySalary. Result must be half the full-time amount. + const partTime = calculateVacationAccrual({ + monthlySalary: 40000, // ignored + vacationRule: 'sammaloneregeln', + vacationDaysPerYear: 25, + semestertillaggRate: 0.0043, + vacationBasis: 20000, + }) + const fullTime = calculateVacationAccrual({ + monthlySalary: 40000, + vacationRule: 'sammaloneregeln', + vacationDaysPerYear: 25, + semestertillaggRate: 0.0043, + vacationBasis: 40000, + }) + expect(partTime.accrual).toBeCloseTo(fullTime.accrual / 2, 1) + // Independent check: 20000/21 × 0.43% × 25 ≈ 102.38 + expect(partTime.accrual).toBeCloseTo(102.38, 1) + }) + + it('semesterersattning: 0 accrual with the correct step label', () => { + const r = calculateVacationAccrual({ + monthlySalary: 40000, + vacationRule: 'semesterersattning', + vacationDaysPerYear: 25, + semestertillaggRate: 0.0043, + vacationBasis: 40000, + }) + expect(r.accrual).toBe(0) + expect(r.steps[0].label).toContain('semesterersättning betald direkt') + }) + + it('none: 0 accrual, no "betald direkt" wording', () => { + const r = calculateVacationAccrual({ + monthlySalary: 40000, + vacationRule: 'none', + vacationDaysPerYear: 25, + semestertillaggRate: 0.0043, + vacationBasis: 40000, + }) + expect(r.accrual).toBe(0) + expect(r.steps[0].label).toBe('Semesteravsättning (avstängd)') + expect(r.steps[0].label).not.toContain('semesterersättning') + }) +}) + describe('calculateKarensavdrag', () => { it('calculates 20% of weekly sjuklön', () => { // Formula: 20% × (40000 × 12/52 × 0.80) diff --git a/lib/salary/account-mapping.ts b/lib/salary/account-mapping.ts index 549727b8..3b737a9a 100644 --- a/lib/salary/account-mapping.ts +++ b/lib/salary/account-mapping.ts @@ -30,6 +30,7 @@ const LINE_ITEM_ACCOUNTS: Record = { vab: '7210', parental_leave: '7210', vacation: '7285', + semesterersattning: '7285', // Travel traktamente_taxfree: '7321', traktamente_taxable: '7322', @@ -60,6 +61,7 @@ export function getLineItemAccount( if (baseAccount === '7281') return '7282' if (baseAccount === '7285') return '7286' } + // Board member uses 7240 if (employmentType === 'board_member') { const baseAccount = LINE_ITEM_ACCOUNTS[itemType] diff --git a/lib/salary/calculation-engine.ts b/lib/salary/calculation-engine.ts index bfe4bb8e..cec9a6e0 100644 --- a/lib/salary/calculation-engine.ts +++ b/lib/salary/calculation-engine.ts @@ -31,7 +31,7 @@ export interface SalaryCalculationInput { paymentDate: string /** Vacation */ - vacationRule: 'procentregeln' | 'sammaloneregeln' | 'none' + vacationRule: 'procentregeln' | 'sammaloneregeln' | 'none' | 'semesterersattning' vacationDaysPerYear: number semestertillaggRate: number @@ -76,6 +76,8 @@ export interface SalaryCalculationResult { avgifterCategory: AvgifterCalculation['category'] vacationAccrual: number vacationAccrualAvgifter: number + /** Semesterersättning paid out directly (vacation_rule = 'semesterersattning'). 0 otherwise. */ + vacationCompensation: number totalEmployerCost: number steps: CalculationStep[] } @@ -96,6 +98,19 @@ function r(x: number): number { return Math.round(x * 100) / 100 } +/** + * Sum vacation-basis line items that ADD to base salary (overtime, bonus, + * etc). Excludes monthly_salary/hourly_salary because those line items mirror + * the engine's own baseSalary computation — counting them would double the + * vacation basis. + */ +function vacationBasisAdditions(lineItems: CalculationLineItem[]): number { + return lineItems + .filter(li => li.isVacationBasis) + .filter(li => li.itemType !== 'monthly_salary' && li.itemType !== 'hourly_salary') + .reduce((sum, li) => sum + li.amount, 0) +} + /** * Format a rate (0.2081) as a Swedish percentage string ("20,81 %"). * Strips trailing zeros, uses Swedish comma as decimal separator, and rounds @@ -206,12 +221,31 @@ export function calculateSalary( }) } - // Gross salary = base + additions + absence (may be negative for deductions) - gross deductions - const grossSalary = r(baseSalary + totalAdditions + totalAbsence - totalGrossDeductions) + // ─── Step 4b: Semesterersättning (paid out directly per cycle) ─── + // When vacation_rule = 'semesterersattning' the employer pays 12% (or 14.4% + // for 30+ days) on top of each paycheck instead of accruing semesterlöneskuld. + // It's part of bruttolön and counts for both tax and avgifter basis. + let vacationCompensation = 0 + if (input.vacationRule === 'semesterersattning') { + const rate = input.vacationDaysPerYear >= 30 ? 0.144 : 0.12 + const compensationBasis = r(baseSalary + vacationBasisAdditions(input.lineItems)) + vacationCompensation = r(compensationBasis * rate) + steps.push({ + label: `Semesterersättning (${fmtPct(rate)})`, + formula: `semesterunderlag × ${fmtPct(rate)} (betalas ut, ingen avsättning)`, + input: { compensation_basis: compensationBasis, rate }, + output: vacationCompensation, + }) + } + + // Gross salary = base + additions + absence (may be negative for deductions) + semesterersättning - gross deductions + const grossSalary = r(baseSalary + totalAdditions + totalAbsence + vacationCompensation - totalGrossDeductions) steps.push({ label: 'Bruttolön', - formula: 'grundlön + tillägg + frånvaro − bruttoavdrag', - input: { base: baseSalary, additions: totalAdditions, absence: totalAbsence, gross_deductions: totalGrossDeductions }, + formula: vacationCompensation > 0 + ? 'grundlön + tillägg + frånvaro + semesterersättning − bruttoavdrag' + : 'grundlön + tillägg + frånvaro − bruttoavdrag', + input: { base: baseSalary, additions: totalAdditions, absence: totalAbsence, vacation_compensation: vacationCompensation, gross_deductions: totalGrossDeductions }, output: grossSalary, }) @@ -350,10 +384,11 @@ export function calculateSalary( } // ─── Step 9: Vacation accrual ─── - const vacationBasisItems = input.lineItems.filter(li => li.isVacationBasis) - const vacationBasis = r( - baseSalary + vacationBasisItems.reduce((sum, li) => sum + li.amount, 0) - ) + // Vacation basis = baseSalary (computed at the top) + any *additional* + // vacation-basis line items (overtime, bonus, etc). We must NOT add + // monthly_salary/hourly_salary line items here — those are auto-created at + // employee-add time and represent the same baseSalary already accounted for. + const vacationBasis = r(baseSalary + vacationBasisAdditions(input.lineItems)) let vacationAccrual: number if (input.vacationRule === 'none') { vacationAccrual = 0 @@ -363,6 +398,14 @@ export function calculateSalary( input: {}, output: 0, }) + } else if (input.vacationRule === 'semesterersattning') { + vacationAccrual = 0 + steps.push({ + label: 'Semesteravsättning (semesterersättning betald direkt)', + formula: 'ingen avsättning — 12 % betalas ut på varje lön', + input: {}, + output: 0, + }) } else if (input.vacationRule === 'procentregeln') { const rate = input.vacationDaysPerYear >= 30 ? 0.144 : 0.12 vacationAccrual = r(vacationBasis * rate) @@ -378,7 +421,9 @@ export function calculateSalary( // 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) + // Use baseSalary (degree-adjusted) — a 50% part-timer's tillägg should be + // half a full-timer's, not the same. + const dailyRate = r(baseSalary / 21) const tillagg = r(dailyRate * input.semestertillaggRate * input.vacationDaysPerYear) vacationAccrual = tillagg steps.push({ @@ -420,6 +465,7 @@ export function calculateSalary( avgifterCategory: avgifterCalc.category, vacationAccrual, vacationAccrualAvgifter, + vacationCompensation, totalEmployerCost, steps, } @@ -593,7 +639,7 @@ export function calculateSjuklon( */ export function calculateVacationAccrual(params: { monthlySalary: number - vacationRule: 'procentregeln' | 'sammaloneregeln' | 'none' + vacationRule: 'procentregeln' | 'sammaloneregeln' | 'none' | 'semesterersattning' vacationDaysPerYear: number semestertillaggRate: number vacationBasis: number @@ -610,6 +656,16 @@ export function calculateVacationAccrual(params: { return { accrual: 0, steps } } + if (params.vacationRule === 'semesterersattning') { + steps.push({ + label: 'Semesteravsättning (semesterersättning betald direkt)', + formula: 'ingen avsättning — 12 % betalas ut på varje lön', + input: {}, + output: 0, + }) + return { accrual: 0, steps } + } + if (params.vacationRule === 'procentregeln') { const rate = params.vacationDaysPerYear >= 30 ? 0.144 : 0.12 const accrual = r(params.vacationBasis * rate) @@ -621,7 +677,10 @@ export function calculateVacationAccrual(params: { }) return { accrual, steps } } else { - const dailyRate = r(params.monthlySalary / 21) + // Sammalöneregeln: tillägg per vacation day. Use vacationBasis as the + // degree-adjusted reference — callers must pass the part-time-adjusted + // monthly amount, never the raw full-time monthlySalary. + const dailyRate = r(params.vacationBasis / 21) const accrual = r(dailyRate * params.semestertillaggRate * params.vacationDaysPerYear) steps.push({ label: `Semesteravsättning (sammalöneregeln ${fmtPct(params.semestertillaggRate)})`, diff --git a/supabase/migrations/20260513160559_salary_semesterersattning.sql b/supabase/migrations/20260513160559_salary_semesterersattning.sql new file mode 100644 index 00000000..aefd4450 --- /dev/null +++ b/supabase/migrations/20260513160559_salary_semesterersattning.sql @@ -0,0 +1,39 @@ +-- ============================================================================= +-- Salary: semesterersättning (vacation compensation paid out directly) +-- ============================================================================= +-- Adds a fourth vacation_rule mode: instead of accruing semesterlöneskuld +-- (BAS 2920), the employer pays 12% (or 14.4% for 30+ days) on top of every +-- paycheck as semesterersättning. Common for hourly/short-term workers where +-- tracking vacation days is overkill. +-- +-- Also adds 'semesterersattning' to salary_line_items.item_type so the engine +-- can emit a derived line item that books to BAS 7285 Semesterlöner. +-- +-- vacation_rule modes: +-- procentregeln — 12%/14.4% accrued to 2920 (Semesterlagen 26§) +-- sammaloneregeln — semestertillägg accrued to 2920 (Semesterlagen 16a§) +-- none — no accrual; vacation cost assumed embedded in monthly salary +-- semesterersattning — 12%/14.4% paid out each cycle to 7285 (no accrual) + +ALTER TABLE public.employees DROP CONSTRAINT IF EXISTS employees_vacation_rule_check; + +ALTER TABLE public.employees ADD CONSTRAINT employees_vacation_rule_check + CHECK (vacation_rule IN ('procentregeln', 'sammaloneregeln', 'none', 'semesterersattning')); + +ALTER TABLE public.salary_line_items DROP CONSTRAINT IF EXISTS salary_line_items_item_type_check; +ALTER TABLE public.salary_line_items ADD CONSTRAINT salary_line_items_item_type_check + CHECK (item_type IN ( + 'monthly_salary', 'hourly_salary', 'overtime', 'bonus', 'commission', + 'gross_deduction_pension', 'gross_deduction_other', + 'benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', + 'benefit_bike', 'benefit_other', + 'sick_karens', 'sick_day2_14', 'sick_day15_plus', + 'vab', 'parental_leave', 'vacation', 'semesterersattning', + 'traktamente_taxfree', 'traktamente_taxable', + 'mileage_taxfree', 'mileage_taxable', + 'net_deduction_advance', 'net_deduction_union', 'net_deduction_benefit_payment', + 'net_deduction_other', + 'correction', 'other' + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index 4d6facce..6033ec79 100644 --- a/types/index.ts +++ b/types/index.ts @@ -2482,7 +2482,7 @@ export interface KPIPreferences { export type EmploymentType = 'employee' | 'company_owner' | 'board_member' export type SalaryType = 'monthly' | 'hourly' export type FSkattStatus = 'a_skatt' | 'f_skatt' | 'fa_skatt' | 'not_verified' -export type VacationRule = 'procentregeln' | 'sammaloneregeln' | 'none' +export type VacationRule = 'procentregeln' | 'sammaloneregeln' | 'none' | 'semesterersattning' export type SalaryRunStatus = 'draft' | 'review' | 'approved' | 'paid' | 'booked' | 'corrected' export type AGIStatus = | 'generated' // XML built from a salary run; nothing sent to SKV yet @@ -2497,7 +2497,7 @@ export type SalaryLineItemType = | 'gross_deduction_pension' | 'gross_deduction_other' | 'benefit_car' | 'benefit_housing' | 'benefit_meals' | 'benefit_wellness' | 'benefit_bike' | 'benefit_other' | 'sick_karens' | 'sick_day2_14' | 'sick_day15_plus' - | 'vab' | 'parental_leave' | 'vacation' + | 'vab' | 'parental_leave' | 'vacation' | 'semesterersattning' | 'traktamente_taxfree' | 'traktamente_taxable' | 'mileage_taxfree' | 'mileage_taxable' | 'net_deduction_advance' | 'net_deduction_union' | 'net_deduction_benefit_payment'