diff --git a/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/__tests__/route.test.ts index 5160c462..d1807158 100644 --- a/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/__tests__/route.test.ts @@ -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({ diff --git a/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route.ts b/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route.ts index dc8ff1fb..f5810fc5 100644 --- a/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route.ts +++ b/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route.ts @@ -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: diff --git a/components/salary/OpeningBalancesPanel.tsx b/components/salary/OpeningBalancesPanel.tsx index 471b99a5..aaf912ee 100644 --- a/components/salary/OpeningBalancesPanel.tsx +++ b/components/salary/OpeningBalancesPanel.tsx @@ -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 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>({}) 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 setDaysRemaining(e.target.value)} disabled={readOnly} className="tabular-nums" /> +
+ + setDaysTaken(e.target.value)} disabled={readOnly} className="tabular-nums" /> +

{t('opening_balances_days_taken_hint')}

+
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 diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 507728e7..083bd80b 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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({}), diff --git a/lib/salary/__tests__/vacation-ledger.test.ts b/lib/salary/__tests__/vacation-ledger.test.ts index be1b238e..e9d27ade 100644 --- a/lib/salary/__tests__/vacation-ledger.test.ts +++ b/lib/salary/__tests__/vacation-ledger.test.ts @@ -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' }) diff --git a/lib/salary/opening-balances.ts b/lib/salary/opening-balances.ts index 928c8f80..43e78af2 100644 --- a/lib/salary/opening-balances.ts +++ b/lib/salary/opening-balances.ts @@ -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 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), diff --git a/lib/salary/vacation-ledger.ts b/lib/salary/vacation-ledger.ts index a3985a1b..0352ad23 100644 --- a/lib/salary/vacation-ledger.ts +++ b/lib/salary/vacation-ledger.ts @@ -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 | 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 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', diff --git a/messages/en.json b/messages/en.json index bced1370..47569d0e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index cf2c9bc5..2ee96581 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/supabase/migrations/20260804120000_employee_opening_balances_taken_days.sql b/supabase/migrations/20260804120000_employee_opening_balances_taken_days.sql new file mode 100644 index 00000000..9fb41569 --- /dev/null +++ b/supabase/migrations/20260804120000_employee_opening_balances_taken_days.sql @@ -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';