Files
accounted/lib/salary/derive-absence-line-items.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

417 lines
15 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 type { SupabaseClient } from '@supabase/supabase-js'
import type { PayrollConfig } from './payroll-config'
import {
calculateVabDeduction,
calculateParentalLeaveDeduction,
} from './absence-calculator'
/**
* Derive payroll line items from per-day absence records.
*
* Why this lives outside the existing absence-calculator: those formulas
* still take `sickDays: number`. They cannot determine sjuklöneperiod
* boundaries, återinsjuknande, or högriskskydd — those depend on actual
* dates, which now live in `salary_absence_days`. This module is the
* bridge: it walks the per-day records and emits correctly-classified
* line items.
*
* Swedish payroll rules implemented:
* - **Sjuklöneperiod** (Sjuklönelagen) = first sick day → calendar-day 14.
* Day 1 is karensavdrag (one per period). Days 2–14 are sjuklön at 80%.
* Day 15+ is Försäkringskassan; employer pays nothing but must report.
* - **Återinsjuknande**: if the next sick day is within 5 calendar days of
* the previous sjuklöneperiod's last day, both merge — no new karens.
* - **Allmänt högriskskydd**: max 10 karensavdrag per rolling 12-month
* window (inclusive of the new one). The 11th is suppressed.
*
* For VAB and parental leave, days are aggregated within the pay period and
* forwarded to the existing calculators with YTD context.
*/
export type AbsenceType =
| 'sick'
| 'vab'
| 'parental'
| 'pregnancy'
| 'care_relative'
| 'study'
| 'other_leave'
export interface AbsenceDay {
absence_date: string // YYYY-MM-DD
absence_type: AbsenceType
hours: number
}
export interface DerivedLineItem {
item_type: 'sick_karens' | 'sick_day2_14' | 'sick_day15_plus' | 'vab' | 'parental_leave'
description: string
quantity: number
amount: number
is_taxable: boolean
is_avgift_basis: boolean
is_vacation_basis: boolean
is_gross_deduction: boolean
}
export interface AggregatedCounts {
sickDays: number
vabDays: number
parentalDays: number
}
export interface DeriveResult {
lineItems: DerivedLineItem[]
aggregated: AggregatedCounts
/** At least one sick day in the pay period fell on segment day 15+ (Försäkringskassan reporting required). */
flagFkReporting: boolean
/** At least one segment passed day 8 in the period (läkarintyg expected). */
flagLakarintyg: boolean
}
interface SjukloneperiodSegment {
startDate: string
endDate: string
/** Number of *sick days* in this merged segment (not calendar days). */
sickDayCount: number
/** True if this segment is the continuation of a prior segment via
* återinsjuknande (gap 1–5 calendar days). No new karensavdrag. */
isAterinsjuknande: boolean
}
const ONE_DAY_MS = 24 * 60 * 60 * 1000
function dateOnly(s: string): Date {
return new Date(`${s}T00:00:00Z`)
}
function daysBetween(a: string, b: string): number {
return Math.round((dateOnly(b).getTime() - dateOnly(a).getTime()) / ONE_DAY_MS)
}
function addDays(d: string, n: number): string {
const t = new Date(dateOnly(d).getTime() + n * ONE_DAY_MS)
return t.toISOString().slice(0, 10)
}
/**
* Walk the (sorted ascending) sick dates and merge them into sjuklöneperioder
* using the SjLL återinsjuknande rule: gap of 1–5 calendar days = same
* period continues; gap ≥ 6 = new period.
*/
export function buildSjukloneperioder(sickDates: string[]): SjukloneperiodSegment[] {
if (sickDates.length === 0) return []
const sorted = [...new Set(sickDates)].sort()
const segments: SjukloneperiodSegment[] = []
let startDate = sorted[0]
let endDate = sorted[0]
let count = 1
const flush = (gapToNext: number | null) => {
segments.push({
startDate,
endDate,
sickDayCount: count,
// The *first* segment is never återinsjuknande (no prior period).
// For subsequent segments, this flag is set below when starting a new one.
isAterinsjuknande: false,
})
void gapToNext
}
for (let i = 1; i < sorted.length; i++) {
const date = sorted[i]
const gap = daysBetween(endDate, date)
if (gap === 0) continue
if (gap >= 1 && gap <= 5) {
// Within 5 calendar days — same period (contiguous OR återinsjuknande)
endDate = date
count += 1
continue
}
// gap > 5 — close current segment, start new one
flush(gap)
startDate = date
endDate = date
count = 1
}
flush(null)
// Annotate isAterinsjuknande based on inter-segment gap (only meaningful if
// the gap from prior segment's end to this segment's start is 1–5 days,
// which the merge logic above already excludes — so this stays false. The
// återinsjuknande logic is fully captured by the merge above; we keep the
// flag for caller introspection if they pass in pre-segmented data.)
return segments
}
export interface DeriveInput {
monthlySalary: number
payrollConfig: PayrollConfig
/** Absence rows in the pay period being calculated. */
periodDays: AbsenceDay[]
/** All sick dates in the prior 12 months (excluding the period). Needed
* to merge segments across pay periods (a period that started in the
* previous month already consumed some of the 14-day window) and to
* count karensavdrag for högriskskydd. */
lookbackSickDates: string[]
/** Year-to-date VAB days for this employee, excluding the current period. */
vabDaysYtd: number
/** Parental leave days in the current pregnancy window (best-effort:
* defaults to calendar-year aggregate). */
parentalDaysPregnancyYtd: number
}
export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
const { monthlySalary, payrollConfig, periodDays } = input
const lineItems: DerivedLineItem[] = []
const r = (x: number) => Math.round(x * 100) / 100
const periodSickDates = periodDays
.filter(d => d.absence_type === 'sick')
.map(d => d.absence_date)
const vabDays = periodDays.filter(d => d.absence_type === 'vab')
const parentalDays = periodDays.filter(d => d.absence_type === 'parental')
let flagFkReporting = false
let flagLakarintyg = false
if (periodSickDates.length > 0) {
const periodMin = periodSickDates[0]
// Build segments over (lookback ∪ period). Segments may straddle the
// boundary; we need the full picture to classify each period day's
// index within its segment.
const allSickDates = [...input.lookbackSickDates, ...periodSickDates]
const segments = buildSjukloneperioder(allSickDates)
// Allmänt högriskskydd (Sjuklönelagen 11§): from the 11th sjuklöneperiod
// within a rolling 12-month window, no karensavdrag is made.
//
// Interpretation: we count *sjuklöneperioder* in the lookback window. The
// law's phrasing — "från och med den 11:e sjukperioden under en
// tolvmånadersperiod görs inget karensavdrag" — keys the cap to the
// period count. An alternative reading is that cap-suppressed periods
// shouldn't count toward future windows (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 karens for an employee who hits the cap
// repeatedly — softer error than the opposite.
//
// TODO: persist per-period karens deduction state if the period-count
// reading produces complaints in the field.
const cap = payrollConfig.maxKarensavdragPerYear ?? 10
const cutoff = addDays(periodMin, -365)
const lookbackOnlySegments = buildSjukloneperioder(
input.lookbackSickDates.filter(d => d >= cutoff),
)
let karensInWindow = lookbackOnlySegments.length
const dailyRate = r(monthlySalary / 21)
const weeklyRate = r(monthlySalary * 12 / 52 * payrollConfig.sjuklonRate)
const karensAmount = r(weeklyRate * payrollConfig.karensavdragFactor)
let day2_14CountTotal = 0
let day15PlusCountTotal = 0
// Walk each segment that touches the period.
for (const seg of segments) {
// Skip segments that don't touch the period at all.
if (seg.endDate < periodMin) continue
if (seg.startDate > periodSickDates[periodSickDates.length - 1]) continue
const segmentStartsInPeriod = seg.startDate >= periodMin
// Karens for the segment? Day 1 of segment, only if it starts in this
// period and the högriskskydd cap isn't hit. (If the segment started
// in a prior pay period, the karens was already booked there; nothing
// to emit here.)
if (segmentStartsInPeriod) {
if (karensInWindow < cap) {
lineItems.push({
item_type: 'sick_karens',
description: `Karensavdrag (${seg.startDate})`,
quantity: 1,
amount: -karensAmount,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: false,
is_gross_deduction: true,
})
karensInWindow += 1
} else {
// Suppressed by allmänt högriskskydd. The employee keeps day-1 pay
// (no karens deduction). Day 1 still consumed from the 14-day
// window but treated as paid normal — emit nothing for it.
}
}
// Classify each *period* sick day in this segment by its segment day
// index (calendar days from segment start, 1-based).
for (const d of periodSickDates) {
if (d < seg.startDate || d > seg.endDate) continue
const segDayIndex = daysBetween(seg.startDate, d) + 1
if (segDayIndex === 1 && segmentStartsInPeriod) {
// already accounted for as karens (or suppressed); skip
continue
}
if (segDayIndex >= 2 && segDayIndex <= 14) {
day2_14CountTotal += 1
if (segDayIndex >= 8) flagLakarintyg = true
} else if (segDayIndex >= 15) {
day15PlusCountTotal += 1
flagFkReporting = true
}
}
}
if (day2_14CountTotal > 0) {
const lostPay = r(dailyRate * day2_14CountTotal)
const sjuklon = r(dailyRate * payrollConfig.sjuklonRate * day2_14CountTotal)
lineItems.push({
item_type: 'sick_day2_14',
description: `Sjuklön dag 2–14 (${day2_14CountTotal} dagar)`,
quantity: day2_14CountTotal,
// Net deduction vs full pay = lostPay - sjuklon (employer pays 80%).
amount: -(lostPay - sjuklon),
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: true,
})
}
if (day15PlusCountTotal > 0) {
const lostPay = r(dailyRate * day15PlusCountTotal)
lineItems.push({
item_type: 'sick_day15_plus',
description: `Sjukfrånvaro dag 15+ (FK) (${day15PlusCountTotal} dagar)`,
quantity: day15PlusCountTotal,
// Employer pays nothing — full daily rate deducted.
amount: -lostPay,
is_taxable: true,
is_avgift_basis: false,
is_vacation_basis: false,
is_gross_deduction: true,
})
}
}
// ── VAB ────────────────────────────────────────────────────────────────
const vabCount = vabDays.length
if (vabCount > 0) {
const vab = calculateVabDeduction(monthlySalary, vabCount, input.vabDaysYtd)
lineItems.push({
item_type: 'vab',
description: `VAB (${vabCount} dagar)`,
quantity: vabCount,
amount: -vab.deduction,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: vab.semesterGrundande,
is_gross_deduction: true,
})
}
// ── Parental leave ─────────────────────────────────────────────────────
const parentalCount = parentalDays.length
if (parentalCount > 0) {
const parental = calculateParentalLeaveDeduction(
monthlySalary,
parentalCount,
input.parentalDaysPregnancyYtd,
)
lineItems.push({
item_type: 'parental_leave',
description: `Föräldraledighet (${parentalCount} dagar)`,
quantity: parentalCount,
amount: -parental.deduction,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: parental.semesterGrundande,
is_gross_deduction: true,
})
}
return {
lineItems,
aggregated: {
sickDays: periodSickDates.length,
vabDays: vabCount,
parentalDays: parentalCount,
},
flagFkReporting,
flagLakarintyg,
}
}
/**
* Convenience: load all DB inputs and derive in one call. Used by the
* salary calculate route.
*/
export async function loadAndDeriveAbsence(params: {
supabase: SupabaseClient
companyId: string
employeeId: string
monthlySalary: number
payrollConfig: PayrollConfig
periodStart: string
periodEnd: string
}): Promise<DeriveResult> {
const { supabase, companyId, employeeId, periodStart, periodEnd } = params
const { data: periodRows, error: periodErr } = await supabase
.from('salary_absence_days')
.select('absence_date, absence_type, hours')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.gte('absence_date', periodStart)
.lte('absence_date', periodEnd)
.order('absence_date', { ascending: true })
if (periodErr) throw new Error(`Failed to load absence days: ${periodErr.message}`)
const periodDays = (periodRows ?? []) as AbsenceDay[]
const lookbackStart = addDays(periodStart, -365)
const { data: lookbackRows, error: lookbackErr } = await supabase
.from('salary_absence_days')
.select('absence_date')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('absence_type', 'sick')
.gte('absence_date', lookbackStart)
.lt('absence_date', periodStart)
if (lookbackErr) throw new Error(`Failed to load absence lookback: ${lookbackErr.message}`)
const lookbackSickDates = (lookbackRows ?? []).map(r => r.absence_date as string)
const yearStart = `${periodStart.slice(0, 4)}-01-01`
const { data: vabYtd } = await supabase
.from('salary_absence_days')
.select('absence_date')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('absence_type', 'vab')
.gte('absence_date', yearStart)
.lt('absence_date', periodStart)
const vabDaysYtd = vabYtd?.length ?? 0
const { data: parentalYtd } = await supabase
.from('salary_absence_days')
.select('absence_date')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('absence_type', 'parental')
.gte('absence_date', yearStart)
.lt('absence_date', periodStart)
const parentalDaysPregnancyYtd = parentalYtd?.length ?? 0
return deriveAbsenceLineItems({
monthlySalary: params.monthlySalary,
payrollConfig: params.payrollConfig,
periodDays,
lookbackSickDates,
vabDaysYtd,
parentalDaysPregnancyYtd,
})
}