-
Notifications
You must be signed in to change notification settings - Fork 0
Leave Accruals
Leave-balance accruals top employees up by a fixed number of days each month, capped at their full entitlement. This complements the year-end rollover, which carries unused leave into the next year.
Each row in leave_balances carries three accrual columns added in migration 025:
-
accrual_rate: days that accrue per calendar month. Zero disables accrual for that balance. -
accrued_to_date: running total the accrual job has granted this year. Guards against double-counting. -
last_accrued_on: the date the job last topped the balance up.
The pure function computeAccrual in lib/leave-accrual.ts decides what to grant:
- If
accrual_rateis zero, nothing accrues. - The anchor is
last_accrued_on, or 1 January for the first run of the year, so a new starter begins accruing from the start of the leave year. -
monthsElapsedcounts only fully elapsed calendar months between the anchor and the run date. - The grant is
months * accrual_rate, clamped soaccrued_to_datenever exceeds the entitlement (the balancetotal).
The logic is idempotent: running twice in the same month grants nothing the second time, because no further whole month has elapsed.
-
Automatically. The
/api/cron/leave-accrualroute runs on the first of each month (Vercel Cron invercel.json, or theleave-accrual.ymlGitHub Actions workflow). It is authenticated with theCRON_SECRETbearer token. - On demand. Admins and accounts staff open Admin, Leave Accruals, preview exactly what the next run would grant, and run it immediately.
A balance has a twenty-four-day entitlement and a rate of two days per month, never accrued.
- A run on 1 April grants six days (January to April, three elapsed months at two days).
- A run on 1 June grants four more days (April to June).
- By the following January the running total reaches the twenty-four-day cap and stays there.
This sequence is covered by the test suite in tests/leave-accrual.test.mjs.
Monthly accrual builds a balance up during the year; the year-end rollover decides how much of it survives into the next year. Both share lib/leave-accrual.ts so the leave year opens and closes against one source of truth.
The pure function computeCarryForward decides each employee's opening balance:
-
remaining = total - used - pending, floored at zero so an over-spent balance never produces a negative carry. -
willCarry = min(remaining, max_carry_forward), the per-employee cap held on the profile (default five days). -
nextYearTotal = (total - carried_forward) + willCarry. The prior year's carried amount is stripped from the base first, so carry-forward never compounds year on year. - All three figures round to two decimal places, so fractional entitlements stay exact.
The /api/cron/year-end-rollover route runs this for every annual balance on 1 January and upserts the new year's row. A worked example: a twenty-four-day balance with twenty-one days used and a five-day cap carries three days, opening the new year at twenty-seven. Take nothing the next year and it still opens at twenty-nine (twenty-four base plus the five-day cap), never thirty-four.
This logic is covered by tests/leave-carry-forward.test.mjs.
-
Preview reports a pending migration. Run migration
025in the Supabase SQL Editor. -
Nothing accrues. Only balances with a positive
accrual_rateaccrue, and only once per elapsed month. - A balance stops growing. It has reached its entitlement cap, which is expected.