Bug/agi vacation submission (#468)

* 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
This commit is contained in:
Mattsson
2026-05-13 19:15:31 +02:00
committed by GitHub
parent f0a2577b8b
commit 6c54f80a93
11 changed files with 676 additions and 17 deletions
@@ -378,6 +378,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
<SelectContent>
<SelectItem value="procentregeln">Procentregeln (12 %)</SelectItem>
<SelectItem value="sammaloneregeln">Sammalöneregeln</SelectItem>
<SelectItem value="semesterersattning">Semesterersättning (betalas ut direkt)</SelectItem>
<SelectItem value="none">Ingen semesteravsättning</SelectItem>
</SelectContent>
</Select>
@@ -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.
</p>
)}
{vacationRule === 'semesterersattning' && (
<p className="text-xs text-muted-foreground">
12 % läggs på varje lönekörning och bokas mot 7285. Ingen semesterlöneskuld byggs upp — vanligt för tim- och visstidsanställda.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="vacation_days_per_year">Semesterdagar per år</Label>
@@ -293,6 +293,7 @@ export default function NewEmployeePage() {
<SelectContent>
<SelectItem value="procentregeln">Procentregeln (12 %)</SelectItem>
<SelectItem value="sammaloneregeln">Sammalöneregeln</SelectItem>
<SelectItem value="semesterersattning">Semesterersättning (betalas ut direkt)</SelectItem>
<SelectItem value="none">Ingen semesteravsättning</SelectItem>
</SelectContent>
</Select>
@@ -301,6 +302,11 @@ export default function NewEmployeePage() {
Ingen avsättning till 2920 bokas. Vanligt för ägare som är enda anställd.
</p>
)}
{vacationRule === 'semesterersattning' && (
<p className="text-xs text-muted-foreground">
12 % läggs på varje lönekörning och bokas mot 7285. Ingen semesterlöneskuld byggs upp — vanligt för tim- och visstidsanställda.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="vacation_days_per_year">Semesterdagar per år</Label>
@@ -29,6 +29,7 @@ const LINE_ITEM_TYPE_LABELS: Record<SalaryLineItemType, string> = {
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)',
@@ -334,6 +334,8 @@ export const POST = withRouteContext(
.filter((li: Record<string, unknown>) => {
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<string, unknown>) => ({
@@ -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
+1 -1
View File
@@ -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([
+4 -1
View File
@@ -50,13 +50,16 @@ export async function generateVacationLiability(
): Promise<VacationLiabilityReport> {
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)
)
+509 -1
View File
@@ -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<LineItem> & { 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)
+2
View File
@@ -30,6 +30,7 @@ const LINE_ITEM_ACCOUNTS: Record<SalaryLineItemType, string> = {
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]
+71 -12
View File
@@ -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)})`,
@@ -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';
+2 -2
View File
@@ -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'