Files
accounted/lib/salary/__tests__/calculation-engine.test.ts
T
Joakim Hansson 4eb1626129 feat(salary): recurring payroll lines per employee (#2042) (#2044)
* feat(salary): recurring payroll lines per employee (#2042)

A standing per-employee payslip row derived into every salary run inside
its validity window, e.g. a benefit-bike bruttolöneavdrag of -670 kr/month.
Mirrors the employee_benefits pattern end to end:

- employee_recurring_lines table with RLS, audit + updated_at triggers, and
  a salary_line_items.source_recurring_line_id back-link; amount sign and
  account format enforced by CHECKs
- run-calculation step 8d3 derives rows with flags computed from the item
  type (gross deductions reduce tax + AGA bases, net deductions post-tax);
  derived rows are excluded from the manual-line set like benefit rows
- CRUD routes under /api/salary/employees/[id]/recurring-lines with the
  same 401/403/404/400 contract as the benefits routes
- EmployeeRecurringLinesPanel on the employee page, sv/en strings
- registered in the BFL full-archive export

Closes #2042

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): address #2044 review: feed recurring rows to the engine, guard deletes

- Derived recurring rows are now appended to the calculateSalary lineItems
  set: they were inserted into salary_line_items but excluded from the
  in-memory calculation, so a recurring deduction never affected the payslip
  math (CodeRabbit, major).
- DELETE deactivates a line that has derived rows instead of hard-deleting:
  ON DELETE SET NULL would turn a draft run's derived row into an apparent
  manual row that recalculation keeps forever; deactivation preserves the
  provenance link and lets the next recalculation drop the draft rows
  (CodeRabbit, major). The panel hides inactive lines.
- POST employee lookup uses maybeSingle and answers 500 on lookup failure,
  404 only on zero rows.
- Panel: try/finally releases loading/submitting on network failure, and a
  request sequence guard stops a stale load from overwriting a newer list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): move employee_recurring_lines off 20260830140000, which upstream now occupies

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): bind employee_id to company_id with a composite FK (review)

The dimensions pattern: UNIQUE (id, company_id) on employees plus a
composite FK, so RLS company scoping cannot be sidestepped by pointing
a recurring line at another company's employee (IDOR, CWE-639).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): address review: deductions only, race-free delete, engine and pg tests

Review round on #2044:

- Blocker: recurring 'other' additions removed from the whitelist, the
  migration CHECK and the panel. calculateSalary only treats
  ADDITION_TYPES as additions, so a recurring taxable addition rendered
  on the payslip without entering gross, tax, AGA or AGI. Re-add only
  together with engine support (recorded in DECISIONS.md).
- Delete race: salary_line_items.source_recurring_line_id is now NO
  ACTION instead of SET NULL; the DELETE route deletes first and falls
  back to deactivation on 23503, so a deletion racing a concurrent
  derivation can never orphan a derived row into an apparent manual row.
  NO ACTION defers to statement end, so company-deletion cascades are
  unaffected.
- Correction runs copy source_benefit_id / source_recurring_line_id, so
  recalculating a correction no longer derives the copied rows a second
  time (pre-existing for benefits, now pinned).
- Engine tests: gross_deduction_other through calculateSalary asserts
  gross, taxable income and avgifterBasis drop while the semester base
  stays; net_deduction_union only moves the paid-out net.
- pg-real tests for the new table: RLS membership, composite FK
  cross-company refusal, deduction-only CHECKs, and the NO ACTION
  back-link blocking deletes of derived-into lines.
- Nice-to-haves: POST rounds the stored amount to ore, the redundant
  single-column employees FK is dropped (composite carries the cascade),
  the schemas.ts comment references the real migration version, and the
  panel explains the validity-window semantics (payment date, bounds
  inclusive, no proration).
- Rebased onto main; the phantom-columns ceiling re-measured at 395 on
  the merged tree.
- DECISIONS.md records the vacation-basis judgment call (semester base
  not reduced by recurring gross deductions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): gate recurring-line writes on the writer role, 404 unmatched deletes

Two findings from the 2026-09-02 review round:

- Superagent P1: the write policies were membership-only, so a read-only
  viewer could write recurring payroll deductions straight through
  PostgREST, bypassing the route's requireWrite. The table now carries
  aa_enforce_company_writer_role, the same gate 20260902093000 attaches
  to every company-scoped table (it also fires inside SECURITY DEFINER
  bodies, where RLS does not apply). The migration is re-versioned to
  20260902140000 so the function exists when a fresh database replays
  the folder in order.
- CodeRabbit: a filtered DELETE reports no error when nothing matches,
  so an unknown or cross-company line answered 200 deleted: true. The
  delete now selects the removed row and answers 404 when it is null.

Tests: pg-real asserts a viewer is refused insert, update and delete
with 42501 while the row survives unchanged, plus a non-member case; the
route tests pin the 404. 896 salary tests green, rebased on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(salary): pin the recurring-line payload column sets

Answers the phantom-column ceiling finding with scoped assertions rather
than a bare ceiling raise: the PATCH route test now asserts the exact
writable column set, and the comment records that the pg-real test covers
the derived-row shape against the real table. Making the PATCH payload a
literal would turn a partial update into last-write-wins, which is why
the shape stays unresolved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(salary): round recurring line amounts with roundOre

check:guards naive-ore-round ratchet: the derived recurring row used Math.round(x * 100) / 100 (baseline 615, +1); roundOre is already imported in run-calculation.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(migrations): guard the employees unique-key add against #2145 merge order

#2145 (expense claims) also adds employees_id_company_id_key. Wrap this
migration's ADD CONSTRAINT in an idempotent DO block so whichever of the
two PRs merges second does not fail on a duplicate constraint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-09-04 16:44:45 +02:00

1560 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi } from 'vitest'
import {
calculateSalary,
calculateKarensavdrag,
calculateSjuklon,
calculateAvgifterRate,
calculateVacationAccrual,
prorateBaseSalaryForPeriod,
} from '../calculation-engine'
import { calculateVacationPay } from '../absence-calculator'
import { recurringLineFlags } from '../recurring-lines'
import type { PayrollConfig } from '../payroll-config'
import type { TaxTableRate } from '../tax-tables'
// Mock personnummer module
vi.mock('../personnummer', () => ({
decryptPersonnummer: (encrypted: string) => {
// Return mock personnummer based on encrypted value
if (encrypted === 'mock_old_person') return '193501011234'
if (encrypted === 'mock_young_person') return '200301011234'
if (encrypted === 'mock_senior_person') return '195801011234'
// Generic helper: 'mock_born_YYYY' resolves to a Jan-1 birth in YYYY.
// Used by ungdomsrabatt boundary tests so each case names its own year
// explicitly rather than depending on a global mock alias.
const m = /^mock_born_(\d{4})$/.exec(encrypted)
if (m) return `${m[1]}01011234`
return '199001011234' // Default: born 1990
},
calculateAgeAtYearStart: (pnr: string, year: number) => {
// Mirrors the real implementation: birth-year based (age attained by
// Dec 31 of the prior year), matching Skatteverket's cohort ranges.
const birthYear = parseInt(pnr.slice(0, 4))
return year - 1 - birthYear
},
}))
const config2026: PayrollConfig = {
configYear: 2026,
avgifterTotal: 0.3142,
avgifterAlderspension: 0.1021,
avgifterSjukforsakring: 0.0355,
avgifterForaldraforsakring: 0.0200,
avgifterEfterlevandepension: 0.0030,
avgifterArbetsmarknad: 0.0264,
avgifterArbetsskada: 0.0010,
avgifterAllmanLoneavgift: 0.1262,
avgifterReduced65plus: 0.1021,
avgifterYouthRate: 0.2081,
avgifterYouthSalaryCap: 25000,
avgifterVaxaStodRate: 0.1021,
avgifterVaxaStodCap: 35000,
avgifterMinimumAnnual: 1000,
egenavgifterTotal: 0.2897,
slpRate: 0.2426,
prisbasbelopp: 59200,
inkomstbasbelopp: 83400,
maxPgi: 625500,
sgiCeiling: 592000,
statligSkattBrytpunkt: 660400,
traktamenteHeldag: 300,
traktamenteHalvdag: 150,
traktamenteNatt: 150,
milersattningEgenBil: 25,
milersattningFormansbilFossil: 12,
milersattningFormansbilEl: 9.50,
kostformanHeldag: 310,
kostformanLunch: 124,
kostformanFrukost: 62,
friskvardCap: 5000,
bilformanSlr: 0.0255,
sjuklonRate: 0.80,
karensavdragFactor: 0.20,
maxKarensavdragPerYear: 10,
reducedAvgiftAge: 67,
}
const emptyTaxRates: TaxTableRate[] = []
function makeBasicInput(overrides = {}) {
return {
employmentType: 'employee' as const,
salaryType: 'monthly' as const,
monthlySalary: 40000,
employmentDegree: 100,
taxTableNumber: null,
taxColumn: 1,
isSidoinkomst: false,
jamkningPercentage: null,
jamkningValidFrom: null,
jamkningValidTo: null,
fSkattStatus: 'a_skatt',
personnummer: 'mock_standard',
paymentDate: '2026-04-25',
vacationRule: 'procentregeln' as const,
vacationDaysPerYear: 25,
semestertillaggRate: 0.0043,
vaxaStodEligible: false,
vaxaStodStart: null,
vaxaStodEnd: null,
lineItems: [],
...overrides,
}
}
describe('calculateSalary', () => {
it('calculates basic monthly salary correctly', () => {
const result = calculateSalary(makeBasicInput(), config2026, emptyTaxRates)
expect(result.grossSalary).toBe(40000)
// With no tax table, falls back to 30%
expect(result.taxWithheld).toBe(12000)
expect(result.netSalary).toBe(28000)
expect(result.avgifterRate).toBe(0.3142)
expect(result.avgifterAmount).toBe(Math.round(40000 * 0.3142 * 100) / 100)
expect(result.vacationAccrual).toBe(Math.round(40000 * 0.12 * 100) / 100)
expect(result.steps.length).toBeGreaterThan(0)
})
it('applies employment degree', () => {
const result = calculateSalary(
makeBasicInput({ employmentDegree: 50 }),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(20000)
expect(result.taxWithheld).toBe(6000) // 30% of 20000
expect(result.netSalary).toBe(14000)
})
it('calculates hourly salary', () => {
const result = calculateSalary(
makeBasicInput({
salaryType: 'hourly',
monthlySalary: 0,
hourlyRate: 250,
hoursWorked: 160,
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(40000) // 250 * 160
})
it('applies sidoinkomst flat 30%', () => {
const result = calculateSalary(
makeBasicInput({ isSidoinkomst: true }),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(12000) // 30% of 40000
})
it('exempts F-skatt compensation from withholding and employer contributions', () => {
const result = calculateSalary(
makeBasicInput({ fSkattStatus: 'f_skatt' }),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(0)
expect(result.netSalary).toBe(40000)
expect(result.avgifterRate).toBe(0)
expect(result.avgifterAmount).toBe(0)
expect(result.avgifterBasis).toBe(0)
expect(result.avgifterCategory).toBe('exempt')
expect(result.vacationAccrualAvgifter).toBe(0)
expect(result.totalEmployerCost).toBe(result.grossSalary + result.vacationAccrual)
})
it('applies unverified flat 30%', () => {
const result = calculateSalary(
makeBasicInput({ fSkattStatus: 'not_verified' }),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(12000)
})
it('applies jämkning when valid', () => {
const result = calculateSalary(
makeBasicInput({
jamkningPercentage: 15,
jamkningValidFrom: '2026-01-01',
jamkningValidTo: '2026-12-31',
}),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(6000) // 15% of 40000
})
it('does not apply jämkning when valid_to is null (#2058: an incomplete beslut is inert)', () => {
// Every write path now rejects this shape; rows stored before that fix
// still exist and must keep falling back to the table (or the 30 % here).
const result = calculateSalary(
makeBasicInput({
jamkningPercentage: 15,
jamkningValidFrom: '2026-01-01',
jamkningValidTo: null,
paymentDate: '2026-04-25',
}),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(12000)
})
it('does not apply jämkning when valid_from is null', () => {
const result = calculateSalary(
makeBasicInput({
jamkningPercentage: 15,
jamkningValidFrom: null,
jamkningValidTo: '2026-12-31',
paymentDate: '2026-04-25',
}),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(12000)
})
it('does not apply jämkning when outside date range', () => {
const result = calculateSalary(
makeBasicInput({
jamkningPercentage: 15,
jamkningValidFrom: '2025-01-01',
jamkningValidTo: '2025-12-31',
paymentDate: '2026-04-25',
}),
config2026,
emptyTaxRates
)
// Should fall back to 30% since jämkning expired
expect(result.taxWithheld).toBe(12000)
})
it('handles line item additions', () => {
const result = calculateSalary(
makeBasicInput({
lineItems: [
{ itemType: 'bonus', amount: 5000, isTaxable: true, isAvgiftBasis: true, isVacationBasis: true, isGrossDeduction: false, isNetDeduction: false },
],
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(45000) // 40000 + 5000
expect(result.taxWithheld).toBe(13500) // 30% of 45000
})
it('applies gross deductions before tax', () => {
const result = calculateSalary(
makeBasicInput({
lineItems: [
{ itemType: 'gross_deduction_pension', amount: -5000, isTaxable: true, isAvgiftBasis: true, isVacationBasis: false, isGrossDeduction: true, isNetDeduction: false },
],
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(35000) // 40000 - 5000
expect(result.grossDeductions).toBe(5000)
expect(result.taxWithheld).toBe(10500) // 30% of 35000 (tax on reduced amount)
})
it('applies net deductions after tax', () => {
const result = calculateSalary(
makeBasicInput({
lineItems: [
{ itemType: 'net_deduction_advance', amount: -3000, isTaxable: false, isAvgiftBasis: false, isVacationBasis: false, isGrossDeduction: false, isNetDeduction: true },
],
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(40000) // Unaffected
expect(result.taxWithheld).toBe(12000) // 30% of 40000 (tax on full amount)
expect(result.netDeductions).toBe(3000)
expect(result.netSalary).toBe(25000) // 40000 - 12000 - 3000
})
it('calculates vacation accrual with procentregeln', () => {
const result = calculateSalary(
makeBasicInput({ vacationRule: 'procentregeln', vacationDaysPerYear: 25 }),
config2026,
emptyTaxRates
)
expect(result.vacationAccrual).toBe(Math.round(40000 * 0.12 * 100) / 100)
})
it('uses 14.4% for 30+ vacation days', () => {
const result = calculateSalary(
makeBasicInput({ vacationRule: 'procentregeln', vacationDaysPerYear: 30 }),
config2026,
emptyTaxRates
)
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({
lineItems: [
{ itemType: 'benefit_car', amount: 3000, isTaxable: true, isAvgiftBasis: true, isVacationBasis: false, isGrossDeduction: false, isNetDeduction: false },
],
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(40000) // Benefits don't add to gross
expect(result.benefitValues).toBe(3000)
expect(result.taxableIncome).toBe(43000) // gross + benefits
expect(result.taxWithheld).toBe(12900) // 30% of 43000
expect(result.netSalary).toBe(27100) // 40000 - 12900
})
it('includes employer cost calculation', () => {
const result = calculateSalary(makeBasicInput(), config2026, emptyTaxRates)
const expectedAvgifter = Math.round(40000 * 0.3142 * 100) / 100
const expectedVacation = Math.round(40000 * 0.12 * 100) / 100
const expectedVacationAvgifter = Math.round(expectedVacation * 0.3142 * 100) / 100
const expectedCost = Math.round((40000 + expectedAvgifter + expectedVacation + expectedVacationAvgifter) * 100) / 100
expect(result.totalEmployerCost).toBe(expectedCost)
})
})
// ============================================================
// Partial-month employment proration
// ============================================================
//
// May 2026 calendar (Mon-Fri only):
// May 1 (Fri), May 4-8 (5), May 11-15 (5), May 18-22 (5), May 25-29 (5)
// → 21 workdays total in May.
// An employee hired May 15 (Fri) works May 15, 18-22, 25-29 → 11 workdays.
// 11 / 21 ≈ 0.5238 → 40 000 SEK × 0.5238 ≈ 20 952,38 SEK.
describe('partial-month employment proration', () => {
it('prorates base salary for an employee hired mid-month', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-05-15',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
// 40 000 × 11 / 21 = 20 952,38 (rounded via engine's r())
expect(result.grossSalary).toBeCloseTo(20952.38, 2)
})
it('prorates base salary for an employee terminated mid-month', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2020-01-01',
employmentEnd: '2026-05-15',
}),
config2026,
emptyTaxRates,
)
// May 1, 4-8, 11-15 = 11 workdays → 40 000 × 11 / 21
expect(result.grossSalary).toBeCloseTo(20952.38, 2)
})
it('does not prorate when the employee covers the full period', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2024-01-01',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBe(40000)
})
it('returns 0 gross when employment does not overlap the period', () => {
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-06-01',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBe(0)
})
it('combines employment proration with employment_degree', () => {
const result = calculateSalary(
makeBasicInput({
employmentDegree: 50,
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-05-15',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
// 40 000 × 50% × 11 / 21 = 20 000 × 11 / 21 ≈ 10 476,19
expect(result.grossSalary).toBeCloseTo(10476.19, 2)
})
it('skips proration when period bounds are missing (backward compat)', () => {
const result = calculateSalary(
makeBasicInput({
employmentStart: '2026-05-15',
employmentEnd: null,
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBe(40000)
})
it('subtracts unpaid_leave once on top of proration (no double-counting)', () => {
// 40 000 × 11/21 (mid-month hire) 2 × 40 000/21 (two unpaid days)
// = 20 952,38 3 809,52
// = 17 142,86
const result = calculateSalary(
makeBasicInput({
periodStart: '2026-05-01',
periodEnd: '2026-05-31',
employmentStart: '2026-05-15',
employmentEnd: null,
lineItems: [
{
itemType: 'unpaid_leave',
amount: -3809.52, // = 2 × Math.round((40000/21) * 100) / 100
isTaxable: true,
isAvgiftBasis: true,
isVacationBasis: false,
isGrossDeduction: false,
isNetDeduction: false,
},
],
}),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBeCloseTo(17142.86, 2)
})
})
describe('prorateBaseSalaryForPeriod', () => {
it('returns 1 when all dates are missing', () => {
expect(prorateBaseSalaryForPeriod(undefined, undefined, undefined, undefined)).toBe(1)
})
it('returns 1 when employment fully covers the period', () => {
expect(
prorateBaseSalaryForPeriod('2020-01-01', null, '2026-05-01', '2026-05-31'),
).toBe(1)
})
it('returns 11/21 for May 2026 mid-month hire on the 15th', () => {
const ratio = prorateBaseSalaryForPeriod(
'2026-05-15',
null,
'2026-05-01',
'2026-05-31',
)
expect(ratio).toBeCloseTo(11 / 21, 6)
})
it('returns 0 when employment ends before the period starts', () => {
expect(
prorateBaseSalaryForPeriod('2020-01-01', '2026-04-30', '2026-05-01', '2026-05-31'),
).toBe(0)
})
it('returns 0 when employment starts after the period ends', () => {
expect(
prorateBaseSalaryForPeriod('2026-06-01', null, '2026-05-01', '2026-05-31'),
).toBe(0)
})
})
// ============================================================
// 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)
// månadslön 40000 × 0.43% × 25/12 ≈ 358.33
expect(r.vacationAccrual).toBeCloseTo(358.33, 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 × 0.8% × 25/12 ≈ 666.67
expect(r.vacationAccrual).toBeCloseTo(666.67, 1)
})
it('values the tillägg on the monthly salary, not the dagslön', () => {
const r = calculateSalary(
makeBasicInput({
vacationRule: 'sammaloneregeln', monthlySalary: 36000,
lineItems: [baseLineItem(36000)],
}),
config2026, emptyTaxRates
)
// Semesterlagen 16a §: the tillägg is a share of the MONTHLY salary per
// vacation day (154.80/day here), and only the month's earned share
// (25/12 days) accrues. Valuing it off a dagslön (36000/21) and booking
// all 25 days every run produced 184.29, under-provisioning 2920 by ~43%.
expect(r.vacationAccrual).toBeCloseTo(322.5, 1)
})
it('accrues a day at exactly what absence-calculator later pays out for it', () => {
const monthlySalary = 36000
const semestertillaggRate = 0.0043
const run = calculateSalary(
makeBasicInput({
vacationRule: 'sammaloneregeln', monthlySalary, semestertillaggRate,
lineItems: [baseLineItem(monthlySalary)],
}),
config2026, emptyTaxRates
)
const accrualPerDay = run.vacationAccrual / (25 / 12)
const payoutPerDay = calculateVacationPay({
monthlySalary,
vacationDaysTaken: 1,
vacationRule: 'sammaloneregeln',
semestertillaggRate,
vacationDaysPerYear: 25,
}).tillagg
// 2920 is credited here and relieved there. When the two bases disagreed
// the liability drifted negative on every taken day, which is the defect
// this pins: provision and payout must value a day identically.
expect(accrualPerDay).toBeCloseTo(payoutPerDay, 2)
})
})
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, kind: 'amount', taxAmount: 3000 },
{ tableYear: 2026, tableNumber: 32, columnNumber: 1, incomeFrom: 20001, incomeTo: 30000, kind: 'amount', taxAmount: 5500 },
{ tableYear: 2026, tableNumber: 32, columnNumber: 1, incomeFrom: 30001, incomeTo: 80000, kind: 'amount', taxAmount: 10000 },
{ tableYear: 2026, tableNumber: 32, columnNumber: 1, incomeFrom: 80001, incomeTo: 9999999, kind: 'percent', taxPercent: 35 },
]
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('applies percent-of-income brackets for salaries above the krona section (over 80 000 kr)', () => {
const r = calculateSalary(
makeBasicInput({ taxTableNumber: 32, taxColumn: 1, monthlySalary: 100000, lineItems: [baseLineItem(100000)] }),
config2026, taxRates
)
expect(r.taxWithheld).toBe(35000)
})
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 × 0.43% × 25/12 ≈ 179.17
expect(partTime.accrual).toBeCloseTo(179.17, 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)
const expected = Math.round(0.20 * (40000 * 12 / 52 * 0.80) * 100) / 100
expect(calculateKarensavdrag(40000, config2026)).toBe(expected)
})
})
describe('calculateSjuklon', () => {
it('calculates karensavdrag + sjuklön for sick days', () => {
const result = calculateSjuklon(40000, 5, config2026)
expect(result.karensavdrag).toBeGreaterThan(0)
expect(result.sjuklon).toBeGreaterThan(0)
expect(result.steps.length).toBeGreaterThan(0)
})
it('handles 1-day sick leave (karens only)', () => {
const result = calculateSjuklon(40000, 1, config2026)
expect(result.karensavdrag).toBeGreaterThan(0)
expect(result.sjuklon).toBe(0) // No sjuklön for day 1
})
it('caps at 13 sjuklön days (day 2-14)', () => {
const result14 = calculateSjuklon(40000, 14, config2026)
const result20 = calculateSjuklon(40000, 20, config2026)
// sjuklön should be the same for 14 and 20 days (capped at day 14)
expect(result14.sjuklon).toBe(result20.sjuklon)
})
})
describe('calculateAvgifterRate', () => {
it('returns the exempt rate for F-skatt before age-based rules', () => {
const result = calculateAvgifterRate(
makeBasicInput({ fSkattStatus: 'f_skatt', personnummer: 'mock_senior_person' }),
config2026,
2026
)
expect(result.rate).toBe(0)
expect(result.amount).toBe(0)
expect(result.basis).toBe(0)
expect(result.category).toBe('exempt')
expect(result.steps).toEqual([
expect.objectContaining({
label: 'Avgiftskategori',
output: null,
}),
])
})
it('returns standard rate for normal employee', () => {
const result = calculateAvgifterRate(
makeBasicInput(),
config2026,
2026
)
expect(result.rate).toBe(0.3142)
expect(result.category).toBe('standard')
})
it('returns reduced rate for 67+ employee', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_senior_person' }),
config2026,
2026
)
expect(result.rate).toBe(0.1021)
expect(result.category).toBe('reduced_65plus')
})
it('keeps the standard rate for born 1959 (turns 67 during 2026)', () => {
// "Fyllt 67 vid årets ingång" 2026 = born 1958 or earlier; born 1959
// gets the reduced rate from 2027. Jan-1 birthday, the exact edge.
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_1959' }),
config2026,
2026
)
expect(result.rate).toBe(0.3142)
expect(result.category).toBe('standard')
})
it('returns 0% for born ≤1937', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_old_person' }),
config2026,
2026
)
expect(result.rate).toBe(0)
expect(result.category).toBe('exempt')
})
it('returns växa-stöd rate when eligible', () => {
const result = calculateAvgifterRate(
makeBasicInput({
vaxaStodEligible: true,
vaxaStodStart: '2025-01-01',
vaxaStodEnd: '2026-12-31',
}),
config2026,
2026
)
expect(result.rate).toBe(0.1021)
expect(result.category).toBe('vaxa_stod')
})
// Ungdomsrabatt 2026-2027 (Prop. 2025/26:66). Eligibility test is
// age >= 18 AND age < 23 at årets ingång, applied by Skatteverket as
// birth-year cohorts (2026: born 2003-2007). Cases below pin all four
// cohort boundaries plus the period-window edges. Fixture birthdays are
// Jan 1, the exact edge the old birthday-inclusive age math misread.
describe('youth rate (ungdomsrabatt 2026-2027)', () => {
it('NOT eligible: born 2008 (17 vid årets ingång 2026, too young)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2008', paymentDate: '2026-05-25' }),
config2026,
2026,
)
expect(result.category).toBe('standard')
expect(result.rate).toBe(0.3142)
})
it('eligible: born 2007 (18 vid årets ingång, lower boundary)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2007', paymentDate: '2026-05-25' }),
config2026,
2026,
)
expect(result.category).toBe('youth')
expect(result.rate).toBe(0.2081)
})
it('eligible: born 2003 (22 vid årets ingång, upper boundary)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2003', paymentDate: '2026-05-25' }),
config2026,
2026,
)
expect(result.category).toBe('youth')
expect(result.rate).toBe(0.2081)
})
// Regression: this is the case Skatteverket's AGI validator rejected.
// The previous implementation incorrectly accepted age 23 at year start.
it('NOT eligible: born 2002 (23 vid årets ingång, just over)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2002', paymentDate: '2026-05-25' }),
config2026,
2026,
)
expect(result.category).toBe('standard')
expect(result.rate).toBe(0.3142)
})
it('NOT eligible: in cohort but paid March 2026 (before period starts)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2004', paymentDate: '2026-03-15' }),
config2026,
2026,
)
expect(result.category).toBe('standard')
expect(result.rate).toBe(0.3142)
})
it('NOT eligible: in cohort but paid October 2027 (after period ends)', () => {
const config2027: PayrollConfig = { ...config2026, configYear: 2027 }
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2005', paymentDate: '2027-10-10' }),
config2027,
2027,
)
expect(result.category).toBe('standard')
expect(result.rate).toBe(0.3142)
})
it('eligible: payment exactly April 1 2026 (period start edge)', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2004', paymentDate: '2026-04-01' }),
config2026,
2026,
)
expect(result.category).toBe('youth')
expect(result.rate).toBe(0.2081)
})
it('eligible: payment exactly September 30 2027 (period end edge)', () => {
const config2027: PayrollConfig = { ...config2026, configYear: 2027 }
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_born_2005', paymentDate: '2027-09-30' }),
config2027,
2027,
)
expect(result.category).toBe('youth')
expect(result.rate).toBe(0.2081)
})
})
})
describe('calculateSalary: youth cap', () => {
// The 25 000 SEK monthly cap is applied by calculateSalary (not
// calculateAvgifterRate) so it has to be exercised through the integration
// path. Salary above the cap: discounted portion at 20.81%, excess at 31.42%.
it('applies 20.81% on first 25 000 SEK and 31.42% on the excess', () => {
const result = calculateSalary(
makeBasicInput({
personnummer: 'mock_born_2004', // age 21 at year start 2026
paymentDate: '2026-06-25',
monthlySalary: 30000,
}),
config2026,
[],
)
// 25 000 × 0.2081 + 5 000 × 0.3142 = 5 202.50 + 1 571.00 = 6 773.50
expect(result.avgifterAmount).toBeCloseTo(6773.5, 1)
expect(result.avgifterCategory).toBe('youth')
})
it('applies pure 20.81% when salary is at or below the cap', () => {
const result = calculateSalary(
makeBasicInput({
personnummer: 'mock_born_2004',
paymentDate: '2026-06-25',
monthlySalary: 20000,
}),
config2026,
[],
)
// 20 000 × 0.2081 = 4 162.00
expect(result.avgifterAmount).toBeCloseTo(4162, 1)
expect(result.avgifterCategory).toBe('youth')
})
})
describe('calculateSalary: shift premiums (OB-tillägg och övertid)', () => {
it('treats ob_weekend as an addition to gross salary', () => {
const result = calculateSalary(
makeBasicInput({
monthlySalary: 40000,
lineItems: [
baseLineItem(40000),
lineItem({
itemType: 'ob_weekend',
amount: 660,
isTaxable: true,
isAvgiftBasis: true,
isVacationBasis: true,
}),
],
}),
config2026,
emptyTaxRates,
)
// Gross = base 40000 + 660 OB
expect(result.grossSalary).toBe(40660)
// Avgifter basis includes the OB amount
expect(result.avgifterBasis).toBe(40660)
})
it('overtime_50 + ob_night flow into additions step', () => {
const result = calculateSalary(
makeBasicInput({
monthlySalary: 40000,
lineItems: [
baseLineItem(40000),
lineItem({
itemType: 'overtime_50',
amount: 1500,
isTaxable: true,
isAvgiftBasis: true,
isVacationBasis: true,
}),
lineItem({
itemType: 'ob_night',
amount: 900,
isTaxable: true,
isAvgiftBasis: true,
isVacationBasis: true,
}),
],
}),
config2026,
emptyTaxRates,
)
// Gross = 40000 + 1500 + 900 = 42400
expect(result.grossSalary).toBe(42400)
expect(result.avgifterBasis).toBe(42400)
// Should label step as additions including OB
const additionStep = result.steps.find((s) => s.label.includes('Tillägg'))
expect(additionStep).toBeDefined()
expect(additionStep?.output).toBe(2400)
})
})
describe('öresavrundning (roundNetToWholeKrona)', () => {
const r2 = (x: number) => Math.round(x * 100) / 100
const bonus = (amount: number) => ({
itemType: 'bonus' as const,
amount,
isTaxable: true,
isAvgiftBasis: true,
isVacationBasis: false,
isGrossDeduction: false,
isNetDeduction: false,
})
const netDeduction = (amount: number) => ({
itemType: 'net_deduction_other' as const,
amount,
isTaxable: false,
isAvgiftBasis: false,
isVacationBasis: false,
isGrossDeduction: false,
isNetDeduction: true,
})
it('is off by default: net keeps its öre and netRounding is 0', () => {
const result = calculateSalary(
makeBasicInput({ fSkattStatus: 'f_skatt', lineItems: [bonus(0.63)] }),
config2026,
emptyTaxRates,
)
expect(result.netSalary).toBe(40000.63)
expect(result.netRounding).toBe(0)
expect(result.steps.find((s) => s.label.includes('Öresavrundning'))).toBeUndefined()
})
it('rounds a .01 net up to the next whole krona (rounding 0.99)', () => {
const result = calculateSalary(
makeBasicInput({ fSkattStatus: 'f_skatt', lineItems: [bonus(0.01)], roundNetToWholeKrona: true }),
config2026,
emptyTaxRates,
)
expect(result.netSalary).toBe(40001)
expect(result.netRounding).toBe(0.99)
})
it('rounds a .99 net up by a single öre', () => {
const result = calculateSalary(
makeBasicInput({ fSkattStatus: 'f_skatt', lineItems: [bonus(0.99)], roundNetToWholeKrona: true }),
config2026,
emptyTaxRates,
)
expect(result.netSalary).toBe(40001)
expect(result.netRounding).toBe(0.01)
})
it('leaves a whole-krona net untouched (no rounding step)', () => {
const result = calculateSalary(
makeBasicInput({ roundNetToWholeKrona: true }),
config2026,
emptyTaxRates,
)
expect(result.netSalary).toBe(28000)
expect(result.netRounding).toBe(0)
expect(result.steps.find((s) => s.label.includes('Öresavrundning'))).toBeUndefined()
})
it('rounds only the net: gross, tax and avgifter stay exact', () => {
// Tax withholding is always whole kronor (SFF 22 kap. 1 §), so the öre
// comes from the gross: 40000.30 12000 = 28000.30 → 28001.
const result = calculateSalary(
makeBasicInput({ lineItems: [bonus(0.3)], roundNetToWholeKrona: true }),
config2026,
emptyTaxRates,
)
expect(result.grossSalary).toBe(40000.3)
expect(result.taxWithheld).toBe(12000)
expect(result.netSalary).toBe(28001)
expect(result.netRounding).toBe(0.7)
expect(result.avgifterAmount).toBe(r2(40000.3 * 0.3142))
const step = result.steps.find((s) => s.label.includes('Öresavrundning'))
expect(step).toBeDefined()
expect(step?.output).toBe(28001)
})
it('applies after nettolöneavdrag', () => {
const result = calculateSalary(
makeBasicInput({
fSkattStatus: 'f_skatt',
lineItems: [netDeduction(-100.75)],
roundNetToWholeKrona: true,
}),
config2026,
emptyTaxRates,
)
// 40000 - 100.75 = 39899.25 → up to 39900
expect(result.netSalary).toBe(39900)
expect(result.netRounding).toBe(0.75)
})
it('never rounds a zero payout', () => {
const result = calculateSalary(
makeBasicInput({ monthlySalary: 0, roundNetToWholeKrona: true }),
config2026,
emptyTaxRates,
)
expect(result.netSalary).toBe(0)
expect(result.netRounding).toBe(0)
})
it('keeps total employer cost on the shared definition (rounding excluded)', () => {
// Payslip summary, KPI cards and lönejournal all recompute employer cost
// as gross + avgifter + semester + avgifter-på-semester; the engine must
// match or the same payslip would print two different totals. The öre
// cost is carried by the 3740 ledger line instead.
const result = calculateSalary(
makeBasicInput({ lineItems: [bonus(0.3)], roundNetToWholeKrona: true }),
config2026,
emptyTaxRates,
)
expect(result.netRounding).toBeGreaterThan(0)
expect(result.totalEmployerCost).toBe(
r2(
result.grossSalary +
result.avgifterAmount +
result.vacationAccrual +
result.vacationAccrualAvgifter,
),
)
})
})
describe('recurring line flags through the engine', () => {
// Pins the payroll math for derived recurring rows: the flags produced by
// recurringLineFlags must actually move gross, the tax base and the AGA
// base when run through calculateSalary (regression guard for #2044).
it('a recurring gross deduction reduces gross, tax base and AGA base, not the semester base', () => {
const flags = recurringLineFlags('gross_deduction_other')
const base = calculateSalary(makeBasicInput(), config2026, emptyTaxRates)
const withDeduction = calculateSalary(
makeBasicInput({
lineItems: [
{
itemType: 'gross_deduction_other' as const,
amount: -500,
isTaxable: flags.is_taxable,
isAvgiftBasis: flags.is_avgift_basis,
isVacationBasis: flags.is_vacation_basis,
isGrossDeduction: flags.is_gross_deduction,
isNetDeduction: flags.is_net_deduction,
},
],
}),
config2026,
emptyTaxRates,
)
expect(withDeduction.grossDeductions).toBe(500)
expect(withDeduction.grossSalary).toBe(base.grossSalary - 500)
expect(withDeduction.taxableIncome).toBe(base.taxableIncome - 500)
expect(withDeduction.avgifterBasis).toBe(base.avgifterBasis - 500)
expect(withDeduction.avgifterAmount).toBeLessThan(base.avgifterAmount)
// The DECISIONS.md judgment call: the semester base stays untouched.
expect(withDeduction.vacationAccrual).toBe(base.vacationAccrual)
})
it('a recurring net deduction reduces only the paid-out net', () => {
const flags = recurringLineFlags('net_deduction_union')
const base = calculateSalary(makeBasicInput(), config2026, emptyTaxRates)
const withDeduction = calculateSalary(
makeBasicInput({
lineItems: [
{
itemType: 'net_deduction_union' as const,
amount: -300,
isTaxable: flags.is_taxable,
isAvgiftBasis: flags.is_avgift_basis,
isVacationBasis: flags.is_vacation_basis,
isGrossDeduction: flags.is_gross_deduction,
isNetDeduction: flags.is_net_deduction,
},
],
}),
config2026,
emptyTaxRates,
)
expect(withDeduction.grossSalary).toBe(base.grossSalary)
expect(withDeduction.taxableIncome).toBe(base.taxableIncome)
expect(withDeduction.avgifterBasis).toBe(base.avgifterBasis)
expect(withDeduction.netDeductions).toBe(300)
expect(withDeduction.netSalary).toBe(base.netSalary - 300)
})
})