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'