Files
accounted/lib/salary/__tests__/calculation-engine.test.ts
T
MattssonandClaude Fable 5 b6332e9ff4 Fix/skv connection flow (#1015)
* feat(salary): one-click AGI submission with filing state machine and success feedback

The AGI panel required users to know that "Ladda ner AGI-fil" was the
generate step, then click submit, signing link, and kvittens manually.
A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path
that does not exist.

- New primary button "Lamna in till Skatteverket" chains the existing
  endpoints client-side: generate XML if missing, POST underlag, poll
  kontrollresultat, create signing link, open Mina Sidor in a tab opened
  synchronously at click (popup-blocker safe). Inline stepper shows each
  step; the four old buttons become collapsed advanced/recovery actions,
  auto-expanded in stale-draft and rejected states. XML download stays
  visible and free for manual filing.
- deriveAgiFilingState() + useAgiSubmission() lift the per-period
  submission record to the run page: the progress rail and salary hero
  now render the real state machine (generated, underlag inskickat,
  vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of
  telling users to "lamna in" an already-submitted declaration.
- Success card with kvittensnummer and signature metadata once signed,
  plus a toast when a poll flips the state while the page is open.
- AGI kvittens cron every 15 min instead of every 2 h so filings signed
  on another device get stamped and emailed promptly.
- Advanced submit also auto-generates, and the stale "Lon -> AGI ->
  Generera" error text now points at the real buttons.

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

* fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup

The bank redirect landed on a blank page for the several seconds the
callback spent exchanging the PSD2 session and mirroring accounts, and
every failed connect attempt left a status='error' row that rendered
forever as an "Atgard kravs" card next to a successful retry, showing
duplicate connections to the same bank.

- Stream a branded "Slutfor bankanslutningen" progress page from the
  callback: the shell flushes before the session exchange starts and a
  script/meta redirect follows when the work completes, with a 30s
  slow-work escape hatch. Fast outcomes (denial, bad params, unknown
  state) keep their plain redirects.
- Delete never-activated connection rows (no session_id, no
  accounts_data) on denial or exchange failure, and sweep leftovers for
  the same bank on the next connect. Established connections keep their
  "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE
  SET NULL so deletion has no dependents.
- Show "Banken ar ansluten: hamtar dina konton" while the settings
  panel loads after the callback instead of an anonymous spinner.

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

* fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip

A direct POST to /api/invoices/[id]/send against an already-issued
invoice re-emailed the customer and posted a second revenue verifikat
(createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
and orphaning the first entry. Only the UI hid the button; the v1 route
and the MCP commit executor already rejected non-drafts.

- Non-draft invoices now return 409 INVOICE_ALREADY_SENT.
- The draft to sent status flip is an optimistic lock (status guard plus
  row-count check); journal entry, accrual schedules, PDF archival and
  the invoice.sent event only run for the request that won the flip.
- On a flip failure the journal entry is deferred: the row stays draft
  and a retry re-runs the pipeline, ending with exactly one verifikat.

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

* fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send

- sendInvoiceFromSchedule now auto-creates an online payment link via
  applyPaymentLinkToInvoice before rendering and passes the payment
  link QR to the PDF: parity with the dashboard and v1 send routes,
  which recurring invoices silently lacked.
- The recurring cron persists last_run_warning both when a claimed run
  throws (hourly retries stay visible on the schedule) and when a stale
  schedule is rolled forward, so a deterministic failure can no longer
  skip a month silently.
- Auto-send is blocked for sandbox companies at the email chokepoint
  (freeze-and-retain: the invoice is still generated as a draft),
  covering both the cron and the run-now route with one guard.

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

* feat(salary): close the Fortnox payroll API gaps (phases 1-4)

Payroll now runs end-to-end through the open API, including onboarding a
client from another payroll system, with every write staged for approval.

- v1: per-employee payslips (list/detail/PDF), payslip line writes,
  run roster attach/remove, absence ranges (per-day storage), jamkning
  fields, cutover opening balances (single + atomic bulk PUT), vacation
  balance + vacation-year-close. PUT added to the wrapper's idempotency/
  test-key set (test keys could otherwise write through PUT).
- MCP: 10 new tools (get_employee/get_payslip/list_absence/
  get_vacation_balance reads + staged update_payslip_line,
  register_absence, create_employee, update_employee,
  set_employee_opening_balances, close_vacation_year), executors, risk
  tiers, op-type CHECK expansions. create_employee encrypts personnummer
  at staging: pending_operations never holds plaintext.
- Scope-map audit retrofit: 11 formerly unmapped tools now scoped;
  BREAKING for keys that relied on the 4 default-allow writes.
- Cutover: employee_opening_balances (derived lock trigger, self-unlocks
  on run correction), engine YTD/karens/liability integration,
  Ingaende saldon section in the employee editor.
- Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the
  hourly/daily divisors; legacy 173/21 preserved exactly at defaults so
  existing pay math is byte-identical.
- Vacation ledger + semesterberedning/arsavslut: recomputed per-year day
  balances (synced on book/correct, non-fatal), year-close with the
  min-20 floor, 5-year sparade-dagar expiry to forced payout, and a
  2920/2940 drift adjustment via the bookkeeping engine; Semester
  dashboard card with preview-then-confirm dialog.
- Fix: Zod 4 defaults leak through .partial(), which made every sparse
  employee PATCH fail validation and reset defaulted columns.

Migrations 20260713100000/101000/110000/121000/122000 (applied to
staging with version rows; prod via merge). vacation_ledger renamed from
20260713120000 to avoid colliding with vat_declaration_totals_rpc.

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

* perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC)

The dominant cost was infrastructure: Vercel functions ran in iad1
(Washington D.C.) while Supabase (DB + auth) lives in eu-north-1
(Stockholm), so every request paid 4-5 transatlantic round trips of
auth + company resolution before doing any real work (measured
530-1900ms for single-query GETs in prod logs). Pin functions to arn1
and cut the redundant work on top:

- vercel.json: functions to arn1, same city as the database
- getActiveCompanyId: preference + first-membership queries run in
  parallel; the fallback result doubles as validation in the common
  single-company case (one round trip instead of two sequential)
- withRouteContext: Server-Timing header and authMs/companyMs/handlerMs
  in the op-completed log, so latency is attributable per phase
- dashboard layout: nav badge counts off the critical path; DashboardNav
  loads them client-side via the new use-worklist-badges SWR hook with
  debounced realtime revalidation
- swr (new dependency, approved): global provider; useCompanySettings
  shares one cache entry across consumers and renders from cache on
  back-navigation instead of re-showing skeletons
- /pending: realtime refetch debounced; bulk operations previously
  fired 4 requests per row-change event
- VAT declaration: new get_vat_declaration_totals RPC returns
  per-account totals, settlement-shape detection (#984) and
  source_type counts in ONE round trip instead of paging every
  entry+line through PostgREST. Account lists stay TS-side parameters
  so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion
  coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts;
  DDL already applied to staging.
- bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat
  dynamic-imports the markdown parser, @vercel/speed-insights (new
  dependency, approved) added for real-user timings

The /salary fetch-waterfall fix from the same effort already landed
inside 2084a756.

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

* fix(invoices): settle öre-rounded payments from the mark-paid flow

An invoice with öresavrundning shows a rounded "Att betala" on the PDF;
the customer pays that amount (up to 50 öre off the stored öre total) and
the invoice-page mark-paid flow rejected it with
MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction
match flow already absorbed the residual to 3740.

- PaymentBookingDialog now proposes the rounded bank leg plus the 3740
  residual line (credit when rounded up, debit when rounded down),
  resolved via getDisplayTotal from the per-invoice override and
  company_settings.ore_rounding.
- settleInvoicePayment and the v1 mark-paid route absorb the sub-krona
  residual, gated by planInvoicePaymentForLines: absorption applies ONLY
  when the caller lines carry the exact residual on 3740; otherwise the
  strict plan applies (sub-krona partials stay partial, no-3740
  overshoots keep the 400), so the GL can never diverge from the AR
  sub-ledger.
- planInvoicePayment absorb-band boundary tightened to >= 1 kr: an
  exactly-1-kr overshoot used to slip past both the guard and the absorb
  branch and silently over-record paid_amount (pre-existing on the
  bank-match path).

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

* fix(security): resolve all 7 PR compliance findings

- ASVS V3.3: per-request CSP nonce on the enable-banking finalize page
  (mirrors the mcp-oauth consent page); inline scripts are nonce-bound
- ASVS V16: decouple callback finalize work from the response stream
  (eager promise + next/server after()) so a client disconnect cannot
  drop session persistence or the consent_granted audit emit
- ISO 27001 A.8.15: failed audit-event emits log through the structured
  logger with a stable message for log-based alerting
- ASVS V2.3: recurring-invoice cron and run-now routes resolve
  isSandboxCompany themselves and pass an explicit suppressAutoSend flag
  (defence in depth around the email chokepoint, freeze-and-retain kept)
- ISO 27001 A.8.11: stagePendingOperation rejects plaintext
  personnummer-bearing keys in params/preview_data (key-based guard;
  EF org numbers make value-matching unsafe)
- ASVS V4.5: employee PATCH body is truly sparse; cleared number fields
  are omitted instead of resetting DB values to hardcoded fallbacks
- ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by
  convention, not 403) on the payslip PDF endpoint

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

* feat: implement vacation-year basis change validation and error handling

- Added tests to block vacation-year basis changes when open balances exist.
- Implemented error handling for open-balances guard query failures in the settings route.
- Enhanced absence route to reject reversed date ranges with a validation error.
- Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability.
- Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules.
- Improved error messaging for vacation year closure adjustments.
- Adjusted employee opening balances handling to preserve audit information during upserts.

* feat(settings): add validation to block vacation-year basis change with open balances

feat(absence): reject reversed date ranges in absence queries

fix(absence): update absence handling to use atomic upserts instead of delete+insert

fix(employee): improve validation for jamkning dates in employee updates

fix(opening-balances): ensure created_by field is preserved during upserts

test(absence): enhance tests for absence range and date validations

test(calculation): add tests for age-based avgifter rates and edge cases

test(semesterberedning): validate vacation year closure adjustments and error handling

test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema

* fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:54:33 +02:00

1259 lines
41 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, beforeEach } from 'vitest'
import {
calculateSalary,
calculateKarensavdrag,
calculateSjuklon,
calculateAvgifterRate,
calculateVacationAccrual,
prorateBaseSalaryForPeriod,
} from '../calculation-engine'
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('applies f-skatt with 0% withholding', () => {
const result = calculateSalary(
makeBasicInput({ fSkattStatus: 'f_skatt' }),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(0)
expect(result.netSalary).toBe(40000)
})
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 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)
// 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)
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 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)
})
})