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
+35
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user