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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
2296c0cd59
commit
9f5a43310b
@@ -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' })
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user