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>
This commit is contained in:
Jakob Wennberg
2026-08-04 19:35:02 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 2296c0cd59
commit 9f5a43310b
11 changed files with 219 additions and 17 deletions
+5
View File
@@ -2776,6 +2776,11 @@ const openingBalancesShape = {
ytd_tax: z.number().min(0).default(0),
ytd_net: z.number().min(0).default(0),
vacation_paid_days_remaining: z.number().min(0).max(40).default(0),
// Paid days already taken in the CURRENT vacation year under the previous
// system. The ledger's cutover-year row derives entitled = remaining +
// taken_this_year and folds this into taken_days; remaining keeps meaning
// "remaining at cutover".
vacation_days_taken_this_year: z.number().min(0).max(40).default(0),
vacation_saved_days_by_year: z
.record(fiscalYearSchema, z.number().min(0).max(40))
.default({}),
@@ -169,6 +169,31 @@ describe('syncVacationLedgerForEmployees', () => {
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 })
@@ -206,6 +231,74 @@ describe('syncVacationLedgerForEmployees', () => {
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' })
+4 -1
View File
@@ -27,6 +27,7 @@ export interface OpeningBalancesInput {
ytd_tax: number
ytd_net: number
vacation_paid_days_remaining: number
vacation_days_taken_this_year: number
vacation_saved_days_by_year: Record<string, number>
opening_semester_liability: number
opening_semester_liability_avgifter: number
@@ -43,7 +44,8 @@ export interface OpeningBalancesRow extends OpeningBalancesInput {
const ROW_COLUMNS =
'id, employee_id, cutover_date, ytd_gross, ytd_tax, ytd_net, ' +
'vacation_paid_days_remaining, vacation_saved_days_by_year, ' +
'vacation_paid_days_remaining, vacation_days_taken_this_year, ' +
'vacation_saved_days_by_year, ' +
'opening_semester_liability, opening_semester_liability_avgifter, ' +
'karens_periods_adjustment, created_at, updated_at'
@@ -242,6 +244,7 @@ export async function setOpeningBalancesBulk(
ytd_tax: roundOre(item.ytd_tax),
ytd_net: roundOre(item.ytd_net),
vacation_paid_days_remaining: item.vacation_paid_days_remaining,
vacation_days_taken_this_year: item.vacation_days_taken_this_year,
vacation_saved_days_by_year: item.vacation_saved_days_by_year,
opening_semester_liability: roundOre(item.opening_semester_liability),
opening_semester_liability_avgifter: roundOre(item.opening_semester_liability_avgifter),
+37 -11
View File
@@ -89,7 +89,7 @@ export async function syncVacationLedgerForEmployees(
const { data: openings, error: openErr } = await supabase
.from('employee_opening_balances')
.select('employee_id, cutover_date, vacation_paid_days_remaining, vacation_saved_days_by_year')
.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 }
@@ -98,6 +98,7 @@ export async function syncVacationLedgerForEmployees(
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]),
)
@@ -145,20 +146,43 @@ export async function syncVacationLedgerForEmployees(
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.
// 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: row.entitled_days,
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),
taken_days: takenInYear(employeeId, row.vacation_year_start) + openingTaken,
saved_days: row.saved_days ?? {},
forced_payout_days: row.forced_payout_days ?? 0,
status: 'open',
@@ -167,11 +191,7 @@ export async function syncVacationLedgerForEmployees(
// Lazy-seed the current year on first touch.
if (!hasCurrentYearRow) {
const opening = openingByEmployee.get(employeeId)
const cutoverInThisYear =
!!opening &&
opening.cutover_date >= currentYearStart &&
opening.cutover_date < getVacationYearBounds(currentYearStart).end
const cutoverInThisYear = cutoverInYear(currentYearStart)
let savedDays: Record<string, number>
if (cutoverInThisYear && opening) {
@@ -186,6 +206,12 @@ export async function syncVacationLedgerForEmployees(
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,
@@ -194,7 +220,7 @@ export async function syncVacationLedgerForEmployees(
// system and outranks any recomputation.
entitled_days:
cutoverInThisYear && opening
? opening.vacation_paid_days_remaining
? (opening.vacation_paid_days_remaining || 0) + seedOpeningTaken
: computeEntitledDays(
basis,
currentYearStart,
@@ -202,7 +228,7 @@ export async function syncVacationLedgerForEmployees(
employee.employment_start,
),
accrued_days: computeAccruedDays(basis, currentYearStart, asOfDate, employee.vacation_days_per_year, employee.employment_start),
taken_days: takenInYear(employeeId, currentYearStart),
taken_days: takenInYear(employeeId, currentYearStart) + seedOpeningTaken,
saved_days: savedDays,
forced_payout_days: 0,
status: 'open',