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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user