Files
accounted/lib/salary/__tests__/vacation-ledger.test.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

321 lines
13 KiB
TypeScript

/**
* Vacation-year boundaries + ledger sync (payroll gap-closure 3.2).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
getClosableYearStart,
getVacationYearBounds,
getVacationYearStart,
} from '@/lib/salary/vacation-year'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
describe('vacation-year helpers', () => {
it('calendar basis: Jan 1 boundary', () => {
expect(getVacationYearStart('2026-07-13', 'calendar')).toBe('2026-01-01')
expect(getVacationYearStart('2026-01-01', 'calendar')).toBe('2026-01-01')
expect(getVacationYearBounds('2026-01-01')).toEqual({ start: '2026-01-01', end: '2027-01-01' })
expect(getClosableYearStart('2026-07-13', 'calendar')).toBe('2025-01-01')
})
it('statutory basis: Apr 1 boundary, Jan-Mar belongs to the previous start', () => {
expect(getVacationYearStart('2026-07-13', 'statutory_apr_mar')).toBe('2026-04-01')
expect(getVacationYearStart('2026-03-31', 'statutory_apr_mar')).toBe('2025-04-01')
expect(getVacationYearStart('2026-04-01', 'statutory_apr_mar')).toBe('2026-04-01')
expect(getVacationYearBounds('2025-04-01')).toEqual({ start: '2025-04-01', end: '2026-04-01' })
expect(getClosableYearStart('2026-02-15', 'statutory_apr_mar')).toBe('2024-04-01')
})
})
describe('syncVacationLedgerForEmployees', () => {
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
let mock: ReturnType<typeof createQueuedMockSupabase>
let supabase: SupabaseClient
let upserted: Array<Record<string, unknown>> | null
beforeEach(() => {
vi.clearAllMocks()
upserted = null
mock = createQueuedMockSupabase()
// Wrap from() to capture the upsert payload while keeping queue behavior.
const originalFrom = mock.supabase.from
mock.supabase.from = vi.fn((table: string) => {
const chain = originalFrom(table) as Record<string, unknown>
return new Proxy(chain as object, {
get(target, prop) {
if (prop === 'upsert' && table === 'employee_vacation_balances') {
return (rows: Array<Record<string, unknown>>) => {
upserted = rows
return (target as Record<string, (...a: unknown[]) => unknown>).upsert?.(rows) ?? target
}
}
return (target as Record<string | symbol, unknown>)[prop]
},
})
}) as never
supabase = mock.supabase as unknown as SupabaseClient
})
const queueBase = (over: {
basis?: string
booked?: Array<{ employee_id: string; vacation_days_taken: number; salary_run: { period_year: number; period_month: number; status: string } }>
openRows?: Array<Record<string, unknown>>
opening?: Array<Record<string, unknown>>
savedLegacy?: number
employmentStart?: string
}) => {
mock.enqueue({ data: { salary_vacation_year_basis: over.basis ?? 'calendar' } }) // company_settings
mock.enqueue({
data: [
{
id: EMPLOYEE_ID,
vacation_days_per_year: 25,
vacation_days_saved: over.savedLegacy ?? 0,
vacation_rule: 'procentregeln',
// Long-tenured by default so the pro-rating cases stay opt-in.
employment_start: over.employmentStart ?? '2015-01-01',
},
],
}) // employees
mock.enqueue({ data: over.opening ?? [] }) // opening balances
mock.enqueue({ data: over.openRows ?? [] }) // existing open ledger rows
mock.enqueue({ data: over.booked ?? [] }) // booked sre rows
mock.enqueue({ data: null }) // upsert result
}
it('lazy-seeds the current year and recomputes taken from booked runs', async () => {
queueBase({
booked: [
{ employee_id: EMPLOYEE_ID, vacation_days_taken: 3, salary_run: { period_year: 2026, period_month: 6, status: 'booked' } },
{ employee_id: EMPLOYEE_ID, vacation_days_taken: 2, salary_run: { period_year: 2026, period_month: 7, status: 'booked' } },
// Prior year: outside the current vacation year bounds.
{ employee_id: EMPLOYEE_ID, vacation_days_taken: 5, salary_run: { period_year: 2025, period_month: 7, status: 'booked' } },
],
})
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
expect(upserted).toHaveLength(1)
const row = upserted![0]
expect(row.vacation_year_start).toBe('2026-01-01')
expect(row.entitled_days).toBe(25)
expect(row.taken_days).toBe(5)
// Calendar basis: sammanfallande year, accrued stays 0.
expect(row.accrued_days).toBe(0)
})
it('pro-rates entitled days for a mid-intjänandeår hire (Semesterlagen 7 §)', async () => {
// Hired 2025-05-19. For semesteråret starting 2026-04-01 the intjänandeår
// is 2025-04-01 to 2026-04-01 (365 days), of which 317 were employed:
// 317/365 x 25 = 21.71, rounded UP to 22. Reporting a flat 25 told the
// employee they had three paid days they had not earned.
queueBase({ basis: 'statutory_apr_mar', employmentStart: '2025-05-19' })
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
const row = upserted![0]
expect(row.vacation_year_start).toBe('2026-04-01')
expect(row.entitled_days).toBe(22)
})
it('gives a full entitlement to someone employed the whole intjänandeår', async () => {
queueBase({ basis: 'statutory_apr_mar', employmentStart: '2015-01-01' })
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
expect(upserted![0].entitled_days).toBe(25)
})
it('accrues from the employment date, not the vacation-year boundary', async () => {
// Hired 2026-07-01, three whole months into the year starting 2026-04-01
// as of 2026-10-15: 3/12 x 25 = 6.25, held to half days = 6.5. Counting
// from the year boundary instead would credit 12.5.
queueBase({ basis: 'statutory_apr_mar', employmentStart: '2026-07-01' })
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-10-15')
expect(result.ok).toBe(true)
expect(upserted![0].accrued_days).toBe(6.5)
})
it('keeps the flat entitlement on a sammanfallande calendar year', async () => {
// Earning and taking share the year there, commonly with förskottssemester,
// so mid-year hires are a CBA question rather than a statutory one.
queueBase({ basis: 'calendar', employmentStart: '2026-05-19' })
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
expect(upserted![0].entitled_days).toBe(25)
})
it('seeds entitled + saved days from the cutover opening row', async () => {
queueBase({
opening: [
{
employee_id: EMPLOYEE_ID,
cutover_date: '2026-07-01',
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: { '2025': 5 },
},
],
})
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
const row = upserted![0]
expect(row.entitled_days).toBe(12.5)
expect(row.saved_days).toEqual({ '2025': 5 })
})
it('seeds cutover-year entitled and taken including pre-cutover taken days', async () => {
queueBase({
opening: [
{
employee_id: EMPLOYEE_ID,
cutover_date: '2026-07-01',
vacation_paid_days_remaining: 12.5,
vacation_days_taken_this_year: 7,
vacation_saved_days_by_year: {},
},
],
booked: [
{ employee_id: EMPLOYEE_ID, vacation_days_taken: 2, salary_run: { period_year: 2026, period_month: 7, status: 'booked' } },
],
})
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
const row = upserted![0]
// entitled = remaining + pre-cutover taken; taken = booked + pre-cutover.
// Remaining (entitled - taken) stays 12.5 - 2 = 10.5.
expect(row.entitled_days).toBe(19.5)
expect(row.taken_days).toBe(9)
})
it('seeds legacy vacation_days_saved under the previous year when no cutover row exists', async () => {
queueBase({ savedLegacy: 4 })
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
const row = upserted![0]
expect(row.saved_days).toEqual({ '2025': 4 })
})
it('recomputes existing open rows instead of duplicating them', async () => {
queueBase({
openRows: [
{
id: 'row-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2026-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 99, // stale: recompute must overwrite from booked runs
saved_days: { '2025': 2 },
forced_payout_days: 0,
status: 'open',
},
],
booked: [
{ employee_id: EMPLOYEE_ID, vacation_days_taken: 1, salary_run: { period_year: 2026, period_month: 5, status: 'booked' } },
],
})
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
expect(upserted).toHaveLength(1)
const row = upserted![0]
expect(row.taken_days).toBe(1)
expect(row.saved_days).toEqual({ '2025': 2 })
})
it('re-derives a stale entitled_days on existing rows (recompute path)', async () => {
// Same mid-intjänandeår hire as the seed-path case: 317/365 x 25 rounds
// UP to 22. The stored row still says the flat 25 from before pro-rating
// existed; carrying it verbatim would preserve the overstatement forever.
queueBase({
basis: 'statutory_apr_mar',
employmentStart: '2025-05-19',
openRows: [
{
id: 'row-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2026-04-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 0,
saved_days: {},
forced_payout_days: 0,
status: 'open',
},
],
})
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
expect(upserted).toHaveLength(1)
expect(upserted![0].entitled_days).toBe(22)
})
it('recompute keeps the opening-derived values on the cutover-year row', async () => {
// The opening balance outranks recomputation for the year containing
// cutover_date, and its pre-cutover taken days must survive every sync
// (not just the first seed) or the seeded value evaporates.
queueBase({
opening: [
{
employee_id: EMPLOYEE_ID,
cutover_date: '2026-07-01',
vacation_paid_days_remaining: 10,
vacation_days_taken_this_year: 8,
vacation_saved_days_by_year: {},
},
],
openRows: [
{
id: 'row-1',
employee_id: EMPLOYEE_ID,
vacation_year_start: '2026-01-01',
entitled_days: 10, // stale pre-fix seed: remaining only
accrued_days: 0,
taken_days: 0,
saved_days: {},
forced_payout_days: 0,
status: 'open',
},
],
booked: [
{ employee_id: EMPLOYEE_ID, vacation_days_taken: 2, salary_run: { period_year: 2026, period_month: 7, status: 'booked' } },
],
})
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(true)
expect(upserted).toHaveLength(1)
const row = upserted![0]
expect(row.entitled_days).toBe(18) // remaining 10 + pre-cutover taken 8
expect(row.taken_days).toBe(10) // booked 2 + pre-cutover taken 8
})
it('accrues toward next year on the statutory basis (elapsed months / 12)', async () => {
queueBase({ basis: 'statutory_apr_mar' })
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-10-15')
expect(result.ok).toBe(true)
const row = upserted![0]
expect(row.vacation_year_start).toBe('2026-04-01')
// Apr -> Oct = 6 whole months: 6/12 x 25 = 12.5.
expect(row.accrued_days).toBe(12.5)
})
it('never throws: DB errors return ok:false (non-fatal contract)', async () => {
mock.enqueue({ data: null }) // company_settings (defaults calendar)
mock.enqueue({ data: null, error: { message: 'boom' } }) // employees fails
const result = await syncVacationLedgerForEmployees(supabase, COMPANY_ID, [EMPLOYEE_ID], '2026-07-13')
expect(result.ok).toBe(false)
})
})