Files
accounted/lib/salary/vacation-ledger.ts
T
9f5a43310b fix(salary): recompute entitled_days on existing ledger rows and record pre-cutover taken days (#1403)
The vacation ledger sync carried entitled_days verbatim on existing open
rows while re-deriving accrued and taken, so a stale entitled value (for
example the flat 25 stored before Semesterlagen 7 § pro-rating existed)
survived every sync. The recompute loop now re-derives entitled the same
way the lazy-seed path does, with the opening-balance cutover still
outranking recomputation for the year containing cutover_date.

Opening balances could also not record paid vacation days already taken
in the cutover year under the previous payroll system. New additive
column employee_opening_balances.vacation_days_taken_this_year (NUMERIC
NOT NULL DEFAULT 0, CHECK 0..40) threaded through the shared service,
the Zod schema, the MCP staging tool (schema + mergeable fields), the
staged-operation executor, the v1 REST routes, and the employee editor
form. Ledger semantics for the cutover year, on both seed and recompute
paths: entitled = remaining + taken_this_year, taken = booked-run taken
+ taken_this_year, so remaining keeps meaning remaining and the seeded
value survives every subsequent sync.

Fixes #1347

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:35:02 +02:00

347 lines
14 KiB
TypeScript

/**
* Vacation balance ledger sync (payroll gap-closure 3.2).
*
* Keeps employee_vacation_balances (per employee, per vacation year) in step
* with reality after every salary-run booking or correction.
*
* RECOMPUTE, never increment: taken_days is re-derived from the currently
* BOOKED runs inside each open year's bounds on every call. Idempotent and
* self-healing; a corrected run simply drops out of the sum with no special
* casing.
*
* Days only: the SEK side of the liability stays derived (2920/2940 are
* booked per run plus the cutover opening term); see the ledger migration
* header for the rationale.
*
* NON-FATAL CONTRACT: callers (book/correct routes) wrap this in try/catch
* and log a warning on failure. A ledger bug must never block a legally
* required booking; the next successful sync heals any gap.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import {
getVacationYearBounds,
getVacationYearStart,
type VacationYearBasis,
} from './vacation-year'
export interface VacationBalanceRow {
id: string
employee_id: string
vacation_year_start: string
entitled_days: number
accrued_days: number
taken_days: number
saved_days: Record<string, number>
forced_payout_days: number
status: 'open' | 'closed'
}
export async function getVacationYearBasis(
supabase: SupabaseClient,
companyId: string,
): Promise<VacationYearBasis> {
const { data } = await supabase
.from('company_settings')
.select('salary_vacation_year_basis')
.eq('company_id', companyId)
.maybeSingle()
return ((data as { salary_vacation_year_basis?: string } | null)?.salary_vacation_year_basis ===
'statutory_apr_mar'
? 'statutory_apr_mar'
: 'calendar') as VacationYearBasis
}
/**
* Recompute the OPEN ledger rows for the given employees and lazy-seed the
* current vacation year's row where none exists.
*
* `asOf` exists for determinism in tests; production callers omit it.
*/
export async function syncVacationLedgerForEmployees(
supabase: SupabaseClient,
companyId: string,
employeeIds: string[],
asOf?: string,
): Promise<{ ok: true } | { ok: false; message: string }> {
if (employeeIds.length === 0) return { ok: true }
const asOfDate = asOf ?? new Date().toISOString().slice(0, 10)
try {
const basis = await getVacationYearBasis(supabase, companyId)
const currentYearStart = getVacationYearStart(asOfDate, basis)
const { data: employees, error: empErr } = await supabase
.from('employees')
.select('id, vacation_days_per_year, vacation_days_saved, vacation_rule, employment_start')
.eq('company_id', companyId)
.in('id', employeeIds)
if (empErr) return { ok: false, message: empErr.message }
const employeeById = new Map(
((employees ?? []) as Array<{
id: string
vacation_days_per_year: number
vacation_days_saved: number
vacation_rule: string
employment_start: string
}>).map((e) => [e.id, e]),
)
const { data: openings, error: openErr } = await supabase
.from('employee_opening_balances')
.select('employee_id, cutover_date, vacation_paid_days_remaining, vacation_days_taken_this_year, vacation_saved_days_by_year')
.eq('company_id', companyId)
.in('employee_id', employeeIds)
if (openErr) return { ok: false, message: openErr.message }
const openingByEmployee = new Map(
((openings ?? []) as Array<{
employee_id: string
cutover_date: string
vacation_paid_days_remaining: number
vacation_days_taken_this_year: number | null
vacation_saved_days_by_year: Record<string, number> | null
}>).map((o) => [o.employee_id, o]),
)
const { data: ledgerRows, error: ledgerErr } = await supabase
.from('employee_vacation_balances')
.select('id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days, status')
.eq('company_id', companyId)
.eq('status', 'open')
.in('employee_id', employeeIds)
if (ledgerErr) return { ok: false, message: ledgerErr.message }
const openRows = (ledgerRows ?? []) as unknown as VacationBalanceRow[]
// Booked vacation days per employee, bucketed later per year bounds.
const { data: bookedRows, error: bookedErr } = await supabase
.from('salary_run_employees')
.select('employee_id, vacation_days_taken, salary_run:salary_runs!inner(period_year, period_month, status)')
.eq('company_id', companyId)
.eq('salary_run.status', 'booked')
.in('employee_id', employeeIds)
if (bookedErr) return { ok: false, message: bookedErr.message }
const booked = ((bookedRows ?? []) as unknown as Array<{
employee_id: string
vacation_days_taken: number
salary_run: { period_year: number; period_month: number; status: string } | null
}>).filter((r) => r.salary_run?.status === 'booked')
const takenInYear = (employeeId: string, yearStart: string): number => {
const bounds = getVacationYearBounds(yearStart)
let sum = 0
for (const row of booked) {
if (row.employee_id !== employeeId) continue
const run = row.salary_run!
const periodDate = `${run.period_year}-${String(run.period_month).padStart(2, '0')}-01`
if (periodDate >= bounds.start && periodDate < bounds.end) {
sum += row.vacation_days_taken || 0
}
}
return sum
}
const upserts: Array<Record<string, unknown>> = []
for (const employeeId of employeeIds) {
const employee = employeeById.get(employeeId)
if (!employee) continue
const opening = openingByEmployee.get(employeeId)
const cutoverInYear = (yearStart: string): boolean =>
!!opening &&
opening.cutover_date >= yearStart &&
opening.cutover_date < getVacationYearBounds(yearStart).end
const rowsForEmployee = openRows.filter((r) => r.employee_id === employeeId)
const hasCurrentYearRow = rowsForEmployee.some(
(r) => r.vacation_year_start === currentYearStart,
)
// Recompute every open year the employee has. entitled_days is
// re-derived like the seed path (a stale stored value would otherwise
// survive forever): the cutover opening balance is the migrated truth
// from the previous system and outranks recomputation for the year
// containing cutover_date; every other year gets Semesterlagen 7 §
// via computeEntitledDays.
for (const row of rowsForEmployee) {
const cutoverRow = cutoverInYear(row.vacation_year_start)
const openingTaken = cutoverRow && opening
? (opening.vacation_days_taken_this_year || 0)
: 0
upserts.push({
company_id: companyId,
employee_id: employeeId,
vacation_year_start: row.vacation_year_start,
entitled_days:
cutoverRow && opening
? (opening.vacation_paid_days_remaining || 0) + openingTaken
: computeEntitledDays(
basis,
row.vacation_year_start,
employee.vacation_days_per_year,
employee.employment_start,
),
accrued_days: computeAccruedDays(basis, row.vacation_year_start, asOfDate, employee.vacation_days_per_year, employee.employment_start),
taken_days: takenInYear(employeeId, row.vacation_year_start) + openingTaken,
saved_days: row.saved_days ?? {},
forced_payout_days: row.forced_payout_days ?? 0,
status: 'open',
})
}
// Lazy-seed the current year on first touch.
if (!hasCurrentYearRow) {
const cutoverInThisYear = cutoverInYear(currentYearStart)
let savedDays: Record<string, number>
if (cutoverInThisYear && opening) {
savedDays = opening.vacation_saved_days_by_year ?? {}
} else if ((employee.vacation_days_saved || 0) > 0) {
// Legacy master field has no origin-year data: attribute the whole
// balance to the year before this one (the most conservative choice
// for the 5-year expiry: it expires EARLIER, never later).
const previousYear = String(Number(currentYearStart.slice(0, 4)) - 1)
savedDays = { [previousYear]: employee.vacation_days_saved }
} else {
savedDays = {}
}
// Days already taken pre-cutover under the previous system: folded
// into BOTH entitled and taken so remaining (entitled - taken) still
// equals the imported vacation_paid_days_remaining.
const seedOpeningTaken = cutoverInThisYear && opening
? (opening.vacation_days_taken_this_year || 0)
: 0
upserts.push({
company_id: companyId,
employee_id: employeeId,
vacation_year_start: currentYearStart,
// A cutover opening balance is the migrated truth from the previous
// system and outranks any recomputation.
entitled_days:
cutoverInThisYear && opening
? (opening.vacation_paid_days_remaining || 0) + seedOpeningTaken
: computeEntitledDays(
basis,
currentYearStart,
employee.vacation_days_per_year,
employee.employment_start,
),
accrued_days: computeAccruedDays(basis, currentYearStart, asOfDate, employee.vacation_days_per_year, employee.employment_start),
taken_days: takenInYear(employeeId, currentYearStart) + seedOpeningTaken,
saved_days: savedDays,
forced_payout_days: 0,
status: 'open',
})
}
}
if (upserts.length === 0) return { ok: true }
const { error: upsertErr } = await supabase
.from('employee_vacation_balances')
.upsert(upserts, { onConflict: 'company_id,employee_id,vacation_year_start' })
if (upsertErr) return { ok: false, message: upsertErr.message }
return { ok: true }
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : 'ledger sync failed' }
}
}
/**
* Intjänade dagar toward NEXT year: only meaningful on the statutory
* Apr-Mar basis, where intjänandeår (this year) and semesterår (next year)
* are split. Sammanfallande calendar years earn and take in the same year,
* so the live number is entitled - taken and accrued stays 0.
*
* Earning starts on the employment date, not on the year boundary: a mid-year
* hire has not earned the months before their first day, and showing them the
* full year's accrual overstates what they may take.
*/
function computeAccruedDays(
basis: VacationYearBasis,
yearStart: string,
asOfDate: string,
vacationDaysPerYear: number,
employmentStart: string,
): number {
if (basis !== 'statutory_apr_mar') return 0
const bounds = getVacationYearBounds(yearStart)
if (asOfDate < bounds.start) return 0
// Employed only after this earning year closed: nothing earned in it.
if (employmentStart >= bounds.end) return 0
const earningStart = employmentStart > bounds.start ? employmentStart : bounds.start
const effectiveAsOf = asOfDate >= bounds.end ? bounds.end : asOfDate
const elapsedMonths = wholeMonthsBetween(earningStart, effectiveAsOf)
// Whole elapsed months / 12, rounded to half days (Semesterlagen 3a §
// rounds UP to whole days at payout; the running accrual view keeps halves
// for transparency).
return Math.round(((elapsedMonths / 12) * vacationDaysPerYear) * 2) / 2
}
/** Whole calendar months from `from` to `to`, day-of-month ignored (the
* pre-existing convention of this view). */
function wholeMonthsBetween(from: string, to: string): number {
const months =
(Number(to.slice(0, 4)) - Number(from.slice(0, 4))) * 12 +
(Number(to.slice(5, 7)) - Number(from.slice(5, 7)))
return months > 0 ? months : 0
}
/**
* Betalda semesterdagar for a semesterår, per Semesterlagen 7 §:
*
* anställningsdagar under intjänandeåret / dagar under intjänandeåret
* x semesterdagar, and "om ett brutet tal då uppstår, avrundas detta till
* närmast högre hela tal" (round UP, always).
*
* The intjänandeår is the twelve months immediately preceding the semesterår
* (3 §), so someone hired part-way through it earns proportionally fewer PAID
* days while keeping the right to take unpaid ones.
*
* Two deliberate omissions, both of which can only overstate entitlement and
* never understate it, so neither can silently deny an employee a paid day:
*
* - 7 § also subtracts days of unpaid full-day absence. The ledger has no
* unpaid-absence day source, so that term is not modelled.
* - 4 § second sentence caps semesterLEDIGHET at five days when employment
* starts after 31 August of the semesterår. That is a cap on days off,
* paid or unpaid, which is a different quantity from the paid days 7 §
* computes, so it does not belong in this number.
*
* Only applied on the statutory basis. Under sammanfallande semesterår the
* employee earns and takes in the same year, commonly with förskottssemester,
* and how a mid-year hire is treated is a collective-agreement question rather
* than a statutory one. Those companies keep the flat entitlement until that
* is decided.
*/
export function computeEntitledDays(
basis: VacationYearBasis,
yearStart: string,
vacationDaysPerYear: number,
employmentStart: string,
): number {
if (basis !== 'statutory_apr_mar') return vacationDaysPerYear
// The intjänandeår is the year immediately BEFORE this semesterår.
const semesterBounds = getVacationYearBounds(yearStart)
const earningStart = shiftYear(semesterBounds.start, -1)
const earningEnd = semesterBounds.start
if (employmentStart >= earningEnd) return 0
const totalDays = daysBetween(earningStart, earningEnd)
if (totalDays <= 0) return vacationDaysPerYear
const employedFrom = employmentStart > earningStart ? employmentStart : earningStart
const employedDays = daysBetween(employedFrom, earningEnd)
const quota = (employedDays / totalDays) * vacationDaysPerYear
return Math.min(vacationDaysPerYear, Math.ceil(quota))
}
function shiftYear(iso: string, delta: number): string {
return `${Number(iso.slice(0, 4)) + delta}${iso.slice(4)}`
}
function daysBetween(from: string, to: string): number {
const ms = Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)
return Math.round(ms / 86_400_000)
}