Files
accounted/lib/salary/__tests__/derive-absence-line-items.test.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

272 lines
9.4 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
deriveAbsenceLineItems,
buildSjukloneperioder,
type AbsenceDay,
type DeriveInput,
} from '../derive-absence-line-items'
import type { PayrollConfig } from '../payroll-config'
const config: PayrollConfig = {
configYear: 2026,
avgifterTotal: 0.3142,
avgifterAlderspension: 0.1021,
avgifterSjukforsakring: 0.0355,
avgifterForaldraforsakring: 0.02,
avgifterEfterlevandepension: 0.003,
avgifterArbetsmarknad: 0.0264,
avgifterArbetsskada: 0.001,
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.5,
kostformanHeldag: 310,
kostformanLunch: 124,
kostformanFrukost: 62,
friskvardCap: 5000,
bilformanSlr: 0.0255,
sjuklonRate: 0.8,
karensavdragFactor: 0.2,
maxKarensavdragPerYear: 10,
reducedAvgiftAge: 67,
}
const days = (entries: Array<[string, AbsenceDay['absence_type']]>): AbsenceDay[] =>
entries.map(([d, t]) => ({ absence_date: d, absence_type: t, hours: 8 }))
const baseInput = (over: Partial<DeriveInput> = {}): DeriveInput => ({
monthlySalary: 30000,
payrollConfig: config,
periodDays: [],
lookbackSickDates: [],
vabDaysYtd: 0,
parentalDaysPregnancyYtd: 0,
...over,
})
describe('buildSjukloneperioder', () => {
it('treats consecutive days as one period', () => {
const segs = buildSjukloneperioder(['2026-04-06', '2026-04-07', '2026-04-08'])
expect(segs).toHaveLength(1)
expect(segs[0].sickDayCount).toBe(3)
expect(segs[0].startDate).toBe('2026-04-06')
expect(segs[0].endDate).toBe('2026-04-08')
})
it('merges segments within 5-day återinsjuknande window', () => {
// Sick Mon-Wed, gap Thu-Fri-Sat-Sun-Mon (5 days), sick Tue
// Gap from last sick (Wed Apr 8) to next (Tue Apr 14) = 6 calendar days → new period
const segs1 = buildSjukloneperioder(['2026-04-06', '2026-04-07', '2026-04-08', '2026-04-14'])
expect(segs1).toHaveLength(2)
// Gap of exactly 5 days → same period
// Wed Apr 8 → Mon Apr 13 = 5 days
const segs2 = buildSjukloneperioder(['2026-04-06', '2026-04-07', '2026-04-08', '2026-04-13'])
expect(segs2).toHaveLength(1)
expect(segs2[0].sickDayCount).toBe(4)
})
it('starts a new period when gap is >5 days', () => {
const segs = buildSjukloneperioder(['2026-04-06', '2026-04-13'])
// gap = 7 → new period
expect(segs).toHaveLength(2)
})
it('returns empty for empty input', () => {
expect(buildSjukloneperioder([])).toEqual([])
})
it('deduplicates duplicate dates', () => {
const segs = buildSjukloneperioder(['2026-04-06', '2026-04-06', '2026-04-07'])
expect(segs).toHaveLength(1)
expect(segs[0].sickDayCount).toBe(2)
})
})
describe('deriveAbsenceLineItems: sick', () => {
it('emits karensavdrag for a single sick day', () => {
const result = deriveAbsenceLineItems(
baseInput({ periodDays: days([['2026-04-06', 'sick']]) }),
)
const karens = result.lineItems.find(li => li.item_type === 'sick_karens')
expect(karens).toBeDefined()
expect(karens!.quantity).toBe(1)
expect(karens!.amount).toBeLessThan(0)
expect(result.lineItems.find(li => li.item_type === 'sick_day2_14')).toBeUndefined()
expect(result.aggregated.sickDays).toBe(1)
})
it('emits karens + day-2-14 for a 5-day period', () => {
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([
['2026-04-06', 'sick'],
['2026-04-07', 'sick'],
['2026-04-08', 'sick'],
['2026-04-09', 'sick'],
['2026-04-10', 'sick'],
]),
}),
)
const karens = result.lineItems.find(li => li.item_type === 'sick_karens')
const day2_14 = result.lineItems.find(li => li.item_type === 'sick_day2_14')
expect(karens).toBeDefined()
expect(day2_14).toBeDefined()
expect(day2_14!.quantity).toBe(4) // days 2-5 of segment
expect(result.flagFkReporting).toBe(false)
})
it('flags läkarintyg when day-8 reached (segment day 8+)', () => {
const periodDays = days(
Array.from({ length: 9 }, (_, i): [string, 'sick'] => [`2026-04-${String(6 + i).padStart(2, '0')}`, 'sick']),
)
const result = deriveAbsenceLineItems(baseInput({ periodDays }))
expect(result.flagLakarintyg).toBe(true)
})
it('flags FK reporting when segment passes day 14', () => {
// 16 consecutive sick days
const periodDays = days(
Array.from({ length: 16 }, (_, i): [string, 'sick'] => {
const day = String(6 + i).padStart(2, '0')
return [`2026-04-${day}`, 'sick']
}),
)
const result = deriveAbsenceLineItems(baseInput({ periodDays }))
expect(result.flagFkReporting).toBe(true)
const day15 = result.lineItems.find(li => li.item_type === 'sick_day15_plus')
expect(day15).toBeDefined()
expect(day15!.quantity).toBe(2) // days 15, 16
})
it('suppresses karens via återinsjuknande when segment started in lookback', () => {
// Prior segment: Apr 1-3. Current period sick day: Apr 6 (gap 3 days → merge).
// Segment now spans Apr 1-6. Period day Apr 6 is segment day 6 → day-2-14, no new karens.
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([['2026-04-06', 'sick']]),
lookbackSickDates: ['2026-04-01', '2026-04-02', '2026-04-03'],
}),
)
expect(result.lineItems.find(li => li.item_type === 'sick_karens')).toBeUndefined()
const day2_14 = result.lineItems.find(li => li.item_type === 'sick_day2_14')
expect(day2_14).toBeDefined()
expect(day2_14!.quantity).toBe(1)
})
it('suppresses karens when högriskskydd cap reached', () => {
// 10 prior single-day karens-eligible periods, each separated by >5 days
const lookback: string[] = []
for (let i = 0; i < 10; i++) {
// periods on the 1st of each prior month
const month = ((4 - 1 + 12 - i - 1) % 12) + 1 // months 3, 2, 1, 12, ...
const year = i < 3 ? 2026 : 2025
lookback.push(`${year}-${String(month).padStart(2, '0')}-01`)
}
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([['2026-04-15', 'sick']]),
lookbackSickDates: lookback,
}),
)
// 10 prior karens in 12-month window → this 11th is suppressed
expect(result.lineItems.find(li => li.item_type === 'sick_karens')).toBeUndefined()
})
})
describe('deriveAbsenceLineItems: VAB', () => {
it('emits VAB line item with deduction', () => {
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([
['2026-04-10', 'vab'],
['2026-04-11', 'vab'],
]),
}),
)
const vab = result.lineItems.find(li => li.item_type === 'vab')
expect(vab).toBeDefined()
expect(vab!.quantity).toBe(2)
expect(vab!.is_vacation_basis).toBe(true) // ≤120 days YTD
expect(result.aggregated.vabDays).toBe(2)
})
it('marks VAB non-vacation-basis when YTD >= 120', () => {
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([['2026-04-10', 'vab']]),
vabDaysYtd: 120,
}),
)
const vab = result.lineItems.find(li => li.item_type === 'vab')
expect(vab!.is_vacation_basis).toBe(false)
})
})
describe('deriveAbsenceLineItems: parental', () => {
it('emits parental line item with deduction', () => {
const result = deriveAbsenceLineItems(
baseInput({
periodDays: days([
['2026-04-10', 'parental'],
['2026-04-11', 'parental'],
['2026-04-12', 'parental'],
]),
}),
)
const parental = result.lineItems.find(li => li.item_type === 'parental_leave')
expect(parental).toBeDefined()
expect(parental!.quantity).toBe(3)
expect(result.aggregated.parentalDays).toBe(3)
})
})
describe('deriveAbsenceLineItems: unpaid_leave', () => {
it('emits unpaid_leave line item with a per-day daily-rate deduction', () => {
const result = deriveAbsenceLineItems(
baseInput({
monthlySalary: 42000, // dailyRate = 42 000 / 21 = 2 000
periodDays: days([
['2026-04-10', 'unpaid_leave'],
['2026-04-13', 'unpaid_leave'],
]),
}),
)
const unpaid = result.lineItems.find(li => li.item_type === 'unpaid_leave')
expect(unpaid).toBeDefined()
expect(unpaid!.quantity).toBe(2)
expect(unpaid!.amount).toBe(-4000)
// false: engine's Step 3 absence sum already subtracts unpaid_leave;
// setting the flag would double-count in Step 4 totalGrossDeductions.
expect(unpaid!.is_gross_deduction).toBe(false)
expect(unpaid!.is_vacation_basis).toBe(false)
expect(result.aggregated.unpaidLeaveDays).toBe(2)
})
})
describe('deriveAbsenceLineItems: empty', () => {
it('returns empty result for no absence', () => {
const result = deriveAbsenceLineItems(baseInput())
expect(result.lineItems).toEqual([])
expect(result.aggregated).toEqual({ sickDays: 0, vabDays: 0, parentalDays: 0, unpaidLeaveDays: 0 })
expect(result.flagFkReporting).toBe(false)
expect(result.flagLakarintyg).toBe(false)
})
})