Files
accounted/lib/salary/__tests__/derive-absence-line-items.test.ts
T
Jakob WennbergandClaude Opus 4.7 f3fd4c0822 feat(salary, skatteverket): per-day absence + AGI Frånvarouppgift + skattekonto + hardening (#388)
* feat(salary): per-day absence tracking with calendar UX

Replace aggregated-day absence counts with per-day records so payroll
calculations can correctly enforce Swedish legal rules that depend on
actual dates: karensavdrag once per sjuklöneperiod, återinsjuknande
within 5 calendar days, allmänt högriskskydd cap of 10 karensavdrag per
rolling 12 months, day-8 läkarintyg flag, day-15 transition to
Försäkringskassan.

Adds:
- salary_absence_days table (RLS, dedup unique on employee+date+type)
- /api/salary/employees/[id]/absence CRUD route
- deriveAbsenceLineItems helper that walks per-day records into
  sjuklöneperioder and emits correctly-classified line items, with the
  existing absence-calculator formulas reused for VAB / parental
- Per-employee pay-spec detail page with month-grid AbsenceCalendar
- Calculate route now derives line items from the calendar before
  running the salary engine, replacing the prior sumQuantity model
- Salary run GET surfaces the formatted Skatteverket arbetsgivare ID
  so downstream UI can build extension URLs without a second round-trip
- GET /salary/runs/[id]/employees/[employeeId] for the detail page

Tests: 15 new unit tests covering segment merge, återinsjuknande
within 5 days, högriskskydd cap, FK transition flag, läkarintyg flag,
VAB/parental semesterlönegrundande ceilings.

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

* feat(skatteverket): harden API client + add NEXT_PUBLIC_SKATTEVERKET_ENABLED feature flag

Three hardening fixes from the prior audit, plus a runtime extension
toggle for phased rollout.

api-client.ts:
- Map 429 to a new SkatteverketAuthError code RATE_LIMITED with a
  Swedish user message. The 4 req/sec local rate limiter normally
  prevents this, but the per-consumer gateway quota can still hit.
- Extend the error union with TOKEN_CORRUPTED for the token-store fix
  below.

token-store.ts:
- Surface decryption failures instead of silently returning null. A
  rotated key or tampered ciphertext used to look like "not connected";
  callers now get TOKEN_CORRUPTED with a clear "anslut igen med BankID"
  message and a structured log line for ops.

Extension dispatcher (app/api/extensions/ext/[...path]/route.ts):
- Per-extension feature flag table. When NEXT_PUBLIC_SKATTEVERKET_ENABLED
  is not exactly "true", the dispatcher returns 503 with code
  EXTENSION_DISABLED, letting ops disable a single integration mid-
  rollout without redeploying or removing it from extensions.config.json.
  UI panels (SkatteverketPanel, AGIPanel) detect the 503 and render an
  empty state.

Tests: 7 api-client cases (401/403/403-Behörighet/429/5xx/200/auth-error
codes) + 2 token-store cases (no-row → null, corrupted → TOKEN_CORRUPTED).

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

* feat(salary): emit AGI Frånvarouppgift per SKV 4785, add AGIPanel for one-click submission

AGI XML upgrade:
- Emit <gem:Franvarouppgift> top-level blocks for VAB and parental
  leave events sourced from salary_absence_days, per SKV 4785 +
  technical doc. Element order matches the spec example file.
  TILLFALLIG_FORALDRAPENNING for VAB / FORALDRAPENNING for parental,
  with FranvaroTimmarTFP (FK825) or FranvaroTimmarFP (FK827) for
  hours. Stable 1-based specifikationsnummer per (employee, period),
  date-sorted. Skipped entirely for periods before 202501.
- Sick days are NOT emitted (they go to Försäkringskassan).
- FK499 TotalSjuklonekostnad now derived from sick_day2_14.quantity
  × dailyRate × 0.80 instead of Math.abs(amount). The line-item
  amount is the net deduction (lostPay − sjuklon), not the cost,
  so the prior formula understated by a factor of four.

AGI submission UI:
- New AGIPanel mirroring SkatteverketPanel's validate → draft → lock
  → BankID-sign → poll-submitted flow. Detects 503 EXTENSION_DISABLED
  and renders a clear empty state. Replaces the bare "Skicka till
  Skatteverket" button on /salary/runs/[id], keeping the AGI XML
  download as a sibling for archival / manual upload fallback.
- Salary run rows now link to the per-employee detail page added in
  the previous commit.

Tests: 14 new agi-xml cases covering element order, type↔hour-field
mapping, specifikationsnummer ordering, fractional-hour formatting,
range clamping (0.01-24.00), period guard at 202501 boundary,
placement after Blankett blocks, multi-employee date ordering,
required-fields invariant, omission when no events.

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

* feat(skatteverket): skattekonto integration — read-only saldo + transactions, daily sync, per-row bokför

Adds read-only Skattekonto v2.1 access via the existing BankID OAuth
flow (extends the OAuth scope with `skattekonto`). Daily background
sync pulls saldo + transactions, dedupes on (company_id, dedup_key),
and surfaces the data in a /skattekonto dashboard plus a settings
panel for connection management.

Backend:
- skattekonto-client.ts: GET /skattekonton/{omfragad}/saldo and
  /transaktioner. Felkod 1–5 mapped to Swedish messages via dedicated
  SkatteverketSkattekontoError.
- skattekonto-sync.ts: parallel saldo + transaktioner fetch, UPSERT
  on (company_id, dedup_key) so kommande rows graduate to tidigare
  in place. Dedup key uses transaktionsidentitet when available, else
  sha256 of (date|amount|text). Caches saldo snapshot in extension_data.
  Emits skattekonto.synced / balance.changed (sign flip) /
  transaction.upcoming (first appearance) / connection.expired.
- skattekonto-booking.ts: keyword→counter-account rules with AB/EF
  differentiation (2510 vs 2012 for preliminärskatt; 2731/2710/2650
  for arbetsgivaravgifter/avdragen skatt/moms; 8423/8313 for
  kostnads-/intäktsränta). Creates a draft journal entry against
  BAS 1630, leaves it for the user to review and commit. Throws
  NO_COUNTER_ACCOUNT instead of guessing when no rule matches.
- Daily cron at 0 4 * * * (Swedish 06:00). Double-gated by
  CRON_SECRET and NEXT_PUBLIC_SKATTEVERKET_ENABLED. Per-company
  cooldown of 1 hour, time budget 50s, distinct `expired` status
  for token-exhaustion separate from generic errors.

Database:
- skattekonto_transactions: company-scoped with RLS, unique
  (company_id, dedup_key), indexed on (company_id, date DESC) and
  (company_id, status). journal_entry_id FK with ON DELETE SET NULL
  so a row can be re-bokförd after entry deletion.

Frontend:
- /skattekonto/page.tsx: dashboard with saldo card, transactions list
  (booked + upcoming), per-row "Bokför" action.
- /settings/skatteverket: connection panel showing scope/expiry.
- Extension toggle in SettingsSidebar (gated by ENABLED_EXTENSION_IDS).

Tests: 9 booking-rule cases (counter-account guessing, AB/EF
divergence, no-match throw) + 7 mapper cases (dedup key stability,
sign convention, kommande→tidigare graduation).

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

* fix: address PR review findings

Build:
- Fix Next.js build failure: Zod refuses .partial() on a refined schema.
  Replace AbsenceRangeQuerySchema.partial().extend(...) in the absence
  DELETE handler with a fresh z.object that defines its own optional
  fields.

Greptile findings (PR #388):
- skattekonto_transactions UPDATE policy was missing WITH CHECK; without
  it a user could mutate company_id to one they don't belong to. Edit
  the original migration for fresh applies + add a follow-up migration
  that drops/recreates the policy with both clauses (already applied
  to prod via Supabase MCP).
- FK499 TotalSjuklonekostnad now reads sjuklonRate from
  run.calculation_params (snapshot taken at calc time) instead of a
  hardcoded 0.80, so an operator override (e.g. CBA-specific rate) is
  honored. Falls back to 0.80 for older runs without the snapshot.
- Rename NEXT_PUBLIC_SKATTEVERKET_ENABLED → SKATTEVERKET_ENABLED so the
  flag is server-side only. NEXT_PUBLIC_* vars are inlined into the
  client bundle at build time, which would create split-brain (server
  503 vs client still rendering enabled flow) on a flag flip without
  redeploy. UI panels detect 503 by response code, not by reading the
  env directly, so no client-visible change is needed.
- Add pg-real RLS smoke tests for both new tables (salary_absence_days
  and skattekonto_transactions): tenant SELECT isolation, UPDATE WITH
  CHECK enforcement, unique-constraint enforcement, cross-tenant dedup
  key allowed.

Swedish compliance review:
- Document the högriskskydd cap interpretation in
  derive-absence-line-items.ts. We count *sjuklöneperioder* in the
  rolling 12-month window, matching the law's plain reading
  ("från och med den 11:e sjukperioden ... görs inget karensavdrag").
  An alternative reading counts only periods that actually had karens
  deducted; that requires persisting per-period karens-deduction state,
  which gnubok doesn't yet do. The period-count reading can over-
  suppress, never under-suppress, so it's the safer default.

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

* fix(test): inline skattekonto fixtures so core-only CI runs without dev_docs

dev_docs/ is gitignored, so the skattekonto-mappers test failed in CI
when it tried to readFileSync from dev_docs/skattekonto(2.1.0)/examples/.
Inline the saldoResponse + transaktionerResponse fixtures verbatim
from the spec; the test still verifies our mappers + dedup-key logic
against the same shape.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:01:21 +02:00

249 lines
8.5 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 — 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 })
expect(result.flagFkReporting).toBe(false)
expect(result.flagLakarintyg).toBe(false)
})
})