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:
@@ -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
@@ -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([
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user