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
@@ -88,6 +88,7 @@ const SAMPLE_ROW = {
ytd_tax: 48000,
ytd_net: 162000,
vacation_paid_days_remaining: 12.5,
vacation_days_taken_this_year: 2,
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 1}`]: 5 },
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
@@ -102,6 +103,7 @@ const VALID_BODY = {
ytd_tax: 48000,
ytd_net: 162000,
vacation_paid_days_remaining: 12.5,
vacation_days_taken_this_year: 2,
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 1}`]: 5 },
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
@@ -244,9 +246,42 @@ describe('PUT /employees/:id/opening-balances', () => {
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.employee_opening_balances_id).toBe(ROW_ID)
expect(body.data.vacation_days_taken_this_year).toBe(2)
expect(body.data.locked).toBe(false)
})
it('rejects vacation_days_taken_this_year below 0', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
{ method: 'PUT', body: JSON.stringify({ ...VALID_BODY, vacation_days_taken_this_year: -1 }) },
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
it('rejects vacation_days_taken_this_year above 40', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
{ method: 'PUT', body: JSON.stringify({ ...VALID_BODY, vacation_days_taken_this_year: 41 }) },
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
it('returns 409 OPENING_BALANCES_LOCKED when a booked run exists', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
@@ -33,6 +33,7 @@ const OpeningBalancesResponse = z.object({
ytd_tax: z.number(),
ytd_net: z.number(),
vacation_paid_days_remaining: z.number(),
vacation_days_taken_this_year: z.number(),
vacation_saved_days_by_year: z.record(z.string(), z.number()),
opening_semester_liability: z.number(),
opening_semester_liability_avgifter: z.number(),
@@ -114,7 +115,7 @@ registerEndpoint({
path: '/api/v1/companies/:companyId/employees/:id/opening-balances',
summary: 'Set an employee\'s payroll cutover opening balances.',
description:
'Full-replace upsert of the cutover state: YTD gross/tax/net for the cutover year, paid vacation days remaining, sparade dagar keyed by origin year (5-year rule), opening semesterlöneskuld SEK (+avgifter), and karens periods not covered by imported absence rows. cutover_date must be the first of a month in the current or previous year, on/after employment_start.',
'Full-replace upsert of the cutover state: YTD gross/tax/net for the cutover year, paid vacation days remaining, paid days already taken this vacation year, sparade dagar keyed by origin year (5-year rule), opening semesterlöneskuld SEK (+avgifter), and karens periods not covered by imported absence rows. cutover_date must be the first of a month in the current or previous year, on/after employment_start.',
useWhen:
'Onboarding one employee during a mid-year migration from Fortnox/Visma/etc. For whole-company onboarding, prefer the bulk PUT /employees/opening-balances.',
doNotUseFor:
@@ -27,6 +27,7 @@ interface OpeningBalancesData {
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
@@ -52,6 +53,7 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
const [ytdTax, setYtdTax] = useState('')
const [ytdNet, setYtdNet] = useState('')
const [daysRemaining, setDaysRemaining] = useState('')
const [daysTaken, setDaysTaken] = useState('')
const [savedByYear, setSavedByYear] = useState<Record<string, string>>({})
const [liability, setLiability] = useState('')
const [liabilityAvgifter, setLiabilityAvgifter] = useState('')
@@ -71,6 +73,7 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
setYtdTax(String(data.ytd_tax))
setYtdNet(String(data.ytd_net))
setDaysRemaining(String(data.vacation_paid_days_remaining))
setDaysTaken(String(data.vacation_days_taken_this_year ?? 0))
setSavedByYear(
Object.fromEntries(
Object.entries(data.vacation_saved_days_by_year ?? {}).map(([y, d]) => [y, String(d)]),
@@ -103,6 +106,7 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
ytd_tax: parseFloat(ytdTax) || 0,
ytd_net: parseFloat(ytdNet) || 0,
vacation_paid_days_remaining: parseFloat(daysRemaining) || 0,
vacation_days_taken_this_year: parseFloat(daysTaken) || 0,
vacation_saved_days_by_year: saved,
opening_semester_liability: parseFloat(liability) || 0,
opening_semester_liability_avgifter: parseFloat(liabilityAvgifter) || 0,
@@ -215,6 +219,12 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
<Input id="ob-days-remaining" type="number" min={0} max={40} step={0.5} value={daysRemaining}
onChange={(e) => setDaysRemaining(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-days-taken">{t('opening_balances_days_taken')}</Label>
<Input id="ob-days-taken" type="number" min={0} max={40} step={0.5} value={daysTaken}
onChange={(e) => setDaysTaken(e.target.value)} disabled={readOnly} className="tabular-nums" />
<p className="text-xs text-muted-foreground">{t('opening_balances_days_taken_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="ob-liability">{t('opening_balances_liability')}</Label>
<Input id="ob-liability" type="number" min={0} value={liability}
+6 -4
View File
@@ -11811,7 +11811,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_set_employee_opening_balances',
title: 'Set Employee Opening Balances (Cutover)',
description: 'Stage payroll cutover state per employee: YTD gross/tax/net, vacation days remaining, sparade dagar by origin year, opening semesterlöneskuld SEK, karens adjustment. An omitted field keeps its stored value; send 0 to clear it. Locked after a booked run.',
description: 'Stage payroll cutover state per employee: YTD gross/tax/net, vacation days remaining and taken this year, sparade dagar by origin year, opening semesterlöneskuld SEK, karens adjustment. An omitted field keeps its stored value; send 0 to clear it. Locked after a booked run.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -11830,6 +11830,7 @@ export const tools: McpTool[] = [
ytd_tax: { type: 'number' },
ytd_net: { type: 'number' },
vacation_paid_days_remaining: { type: 'number' },
vacation_days_taken_this_year: { type: 'number', description: 'Paid days already taken this vacation year under the previous system (0-40)' },
vacation_saved_days_by_year: { type: 'object', description: 'Origin year -> days, e.g. {"2025": 5}; {} clears' },
opening_semester_liability: { type: 'number', description: 'SEK on 2920 (report-only; booked via SIE)' },
opening_semester_liability_avgifter: { type: 'number', description: 'SEK on 2940' },
@@ -11845,9 +11846,9 @@ export const tools: McpTool[] = [
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
// Sparse merge, NOT full replace. OpeningBalancesBulkSchema carries a
// .default() on all eight non-key fields (and .partial() would not strip
// .default() on all nine non-key fields (and .partial() would not strip
// them: Zod applies defaults through it), so parsing the caller's args
// straight into the 9-column upsert resets ytd_tax, ytd_net, vacation
// straight into the 10-column upsert resets ytd_tax, ytd_net, vacation
// days, sparade dagar, the opening semesterlöneskuld and the karens
// adjustment to 0 whenever an agent corrects a single figure. Same
// defence as gnubok_update_employee: keep only the keys actually sent,
@@ -11875,7 +11876,8 @@ export const tools: McpTool[] = [
const MERGEABLE_FIELDS = [
'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',
] as const
+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',
+2
View File
@@ -6135,6 +6135,8 @@
"opening_balances_ytd_net": "Net salary (SEK)",
"opening_balances_vacation_heading": "Vacation",
"opening_balances_days_remaining": "Paid days left this year",
"opening_balances_days_taken": "Paid days taken this year",
"opening_balances_days_taken_hint": "Paid vacation days already taken this year in the previous payroll system.",
"opening_balances_liability": "Vacation pay liability (2920)",
"opening_balances_liability_avgifter": "Charges on the liability (2940)",
"opening_balances_saved_heading": "Saved days per earning year",
+2
View File
@@ -6135,6 +6135,8 @@
"opening_balances_ytd_net": "Nettolön (SEK)",
"opening_balances_vacation_heading": "Semester",
"opening_balances_days_remaining": "Betalda dagar kvar i år",
"opening_balances_days_taken": "Uttagna betalda dagar i år",
"opening_balances_days_taken_hint": "Betalda semesterdagar som redan tagits ut i år i det tidigare lönesystemet.",
"opening_balances_liability": "Semesterlöneskuld (2920)",
"opening_balances_liability_avgifter": "Avgifter på skulden (2940)",
"opening_balances_saved_heading": "Sparade dagar per intjänandeår",
@@ -0,0 +1,23 @@
-- Migration: employee_opening_balances.vacation_days_taken_this_year
-- (issue #1347: opening balances could not record already-taken days).
--
-- A company switching to Accounted mid-year may have paid vacation days
-- already taken in the current vacation year under the previous payroll
-- system. Those days are invisible to the ledger sync, which re-derives
-- taken_days purely from BOOKED Accounted runs, so the cutover-year row
-- understated both entitled and taken.
--
-- Ledger semantics for the vacation year containing cutover_date:
-- entitled_days = vacation_paid_days_remaining + vacation_days_taken_this_year
-- taken_days = taken in booked runs + vacation_days_taken_this_year
-- vacation_paid_days_remaining keeps meaning "remaining at cutover"
-- (backward compatible: the public v1 REST API already exposes it).
--
-- The column rides the existing enforce_opening_balances_lock trigger:
-- editable until the employee appears in a booked salary run.
ALTER TABLE public.employee_opening_balances
ADD COLUMN vacation_days_taken_this_year NUMERIC NOT NULL DEFAULT 0
CHECK (vacation_days_taken_this_year >= 0 AND vacation_days_taken_this_year <= 40);
NOTIFY pgrst, 'reload schema';