Skip to content

Leave Accruals

sarmakska edited this page Jun 7, 2026 · 2 revisions

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.

Model

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.

Calculation

The pure function computeAccrual in lib/leave-accrual.ts decides what to grant:

  1. If accrual_rate is zero, nothing accrues.
  2. 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.
  3. monthsElapsed counts only fully elapsed calendar months between the anchor and the run date.
  4. The grant is months * accrual_rate, clamped so accrued_to_date never exceeds the entitlement (the balance total).

The logic is idempotent: running twice in the same month grants nothing the second time, because no further whole month has elapsed.

Running accruals

  • Automatically. The /api/cron/leave-accrual route runs on the first of each month (Vercel Cron in vercel.json, or the leave-accrual.yml GitHub Actions workflow). It is authenticated with the CRON_SECRET bearer token.
  • On demand. Admins and accounts staff open Admin, Leave Accruals, preview exactly what the next run would grant, and run it immediately.

Worked example

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.

Year-end carry-forward

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:

  1. remaining = total - used - pending, floored at zero so an over-spent balance never produces a negative carry.
  2. willCarry = min(remaining, max_carry_forward), the per-employee cap held on the profile (default five days).
  3. 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.
  4. 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.

Troubleshooting

  • Preview reports a pending migration. Run migration 025 in the Supabase SQL Editor.
  • Nothing accrues. Only balances with a positive accrual_rate accrue, and only once per elapsed month.
  • A balance stops growing. It has reached its entitlement cap, which is expected.

Related

Clone this wiki locally