Files
accounted/lib/salary/derive-absence-line-items.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

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

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

464 lines
17 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 { daysBetweenIso } from '@/lib/dates/iso'
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'
| 'unpaid_leave'
| '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' | 'unpaid_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
unpaidLeaveDays: 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 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 = daysBetweenIso(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
/** Cutover state (payroll gap-closure 2.2): karens periods in the 12
* months before cutover NOT represented by imported salary_absence_days
* rows. Added to the högriskskydd window count so a mid-year switcher's
* cap position carries over. The caller zeroes this once the lookback
* window no longer overlaps pre-cutover time. */
karensPeriodsAdjustment?: number
/** Work-schedule daily-rate divisor (arbetsschema-lite). Defaults to the
* legacy 21 (5-day week); part-time schedules pass
* dailyDivisor(workdays_per_week) from lib/salary/work-schedule. */
dailyDivisor?: 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')
const unpaidLeaveDays = periodDays.filter(d => d.absence_type === 'unpaid_leave')
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 Accounted 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),
)
// Cutover adjustment: karens periods from the previous payroll system
// that were never imported as day rows. Over-suppression of karens is
// the softer error (consistent with the period-count reading above).
let karensInWindow = lookbackOnlySegments.length + (input.karensPeriodsAdjustment ?? 0)
// weeklyRate stays monthly x 12/52 by construction (schedule-independent);
// only the DAILY rate scales with the workday schedule.
const dailyRate = r(monthlySalary / (input.dailyDivisor ?? 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 = daysBetweenIso(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, input.dailyDivisor)
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,
input.dailyDivisor,
)
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,
})
}
// ── Unpaid leave (tjänstledighet utan lön) ─────────────────────────────
// Each day reduces gross pay by one daily rate (monthlySalary / 21: same
// convention used elsewhere in the engine). Not semestergrundande per SemL
// 17 § (only paid leave types accrue vacation).
//
// is_gross_deduction is deliberately false: the engine's Step 3 absence
// sum already subtracts items whose item_type is 'unpaid_leave', so setting
// the flag would double-count the amount in Step 4's gross_deduction sum.
const unpaidLeaveCount = unpaidLeaveDays.length
if (unpaidLeaveCount > 0) {
const dailyRate = r(monthlySalary / (input.dailyDivisor ?? 21))
const deduction = r(dailyRate * unpaidLeaveCount)
lineItems.push({
item_type: 'unpaid_leave',
description: `Tjänstledighet utan lön (${unpaidLeaveCount} dagar)`,
quantity: unpaidLeaveCount,
amount: -deduction,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: false,
is_gross_deduction: false,
})
}
return {
lineItems,
aggregated: {
sickDays: periodSickDates.length,
vabDays: vabCount,
parentalDays: parentalCount,
unpaidLeaveDays: unpaidLeaveCount,
},
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
/** See DeriveInput.karensPeriodsAdjustment. */
karensPeriodsAdjustment?: number
/** See DeriveInput.dailyDivisor. */
dailyDivisor?: number
}): 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,
karensPeriodsAdjustment: params.karensPeriodsAdjustment,
dailyDivisor: params.dailyDivisor,
})
}