PTO Ledger: Accruals, Leave and the Year-End Policy Change
MediumAI-assisted coding · AI-Assisted Debugging
PTO Ledger: Accruals, Leave and the Year-End Policy Change
You've joined the people-systems team that owns the paid-time-off ledger. It credits each employee time off every pay period, charges leave against the balance, and answers "how many hours do I have?" for the HR portal. Payroll's quarterly audit has found three discrepancies in the existing ledger, and Finance has two policy changes queued behind them. All durations are integer minutes (HR talks in hours; 8 hours is 480 minutes). Dates are YYYY-MM-DD strings.
Codebase
| File | What it does |
|---|---|
pto/api.py | run(operations) entry point and the Dispatcher that routes each operation |
pto/ledger.py | PtoLedger: time advancing, hires, holidays, leave requests, balances |
pto/policy.py | tier_at and PtoPolicy.inflow_on: what an employee is credited on a given day |
pto/projection.py | Looks ahead: scheduled credits and the "would this leave breach the limit?" check |
pto/workdays.py | HolidayCalendar and leave_minutes, the cost of a leave request |
pto/dates.py | Strict date parsing, pay-period starts, anniversaries, completed years |
pto/year_end.py | Placeholder for Part 2 |
pto/models.py | Employee, Entry, LeaveRequest records |
pto/config.py | Every policy constant (rates, caps, limits, status names) |
pto/errors.py | PtoError and every error code |
tests/ | Existing end-to-end tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh ledger and returns one result per operation. Every operation is [name, ...args]. Errors are returned as "error: <code>" strings, never raised. An unknown name returns "error: unknown operation"; a wrong number of arguments or an argument of the wrong type (strings where text is expected, integers where a count is expected; true/false are not integers) returns "error: invalid arguments". That check comes before any other. An operation that is not a non-empty list starting with a string is "error: invalid arguments" too.
The ledger has a clock. It starts at 2025-12-31 and only moves forward when you advance it. Nothing is ever back-dated: an employee is only credited for days the clock passes *after* they were entered, whatever their hire date says.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
hire | emp_id, hire_date | true | employee exists, invalid date |
holiday | date | true (idempotent) | invalid date |
advance | date | list of forfeiture rows (always [] until Part 2) | invalid date |
request | req_id, emp_id, start, end, part | true | request exists, employee not found, invalid date, invalid day part |
approve | req_id | minutes the leave costs | request not found, request not pending, already started, no working days, limit exceeded |
cancel | req_id | minutes given back | request not found, not cancellable |
balance | emp_id | balance in minutes | employee not found |
history | emp_id | [[date, kind, minutes], ...], oldest first | employee not found |
status | req_id | "pending", "approved", "cancelled" or "revoked" | request not found |
- A date is valid only if it is exactly
YYYY-MM-DDand a real calendar day (2026-02-30and2026-2-3are invalid).hireaccepts any valid date, past or future.advancemust not move the clock backwards (the same day is a no-op returning[]). - Days are processed one at a time. For each day
Dthe clock passes, in this order: (1) year-end rollover (Part 2), (2) credits, (3) leave debits for requests starting onD. Within a step, ledger entries appear inhistoryin the order they were posted. - Pay periods start on the 1st and the 16th of every month. On each period start, every employee whose hire date is on or before that day is credited with the accrual rate of their tier (an
accrualentry). - Tenure tiers. Completed years of service on a day
Dis the number of work anniversaries on or beforeD(the *n*-th anniversary is the hire date plus *n* calendar years; a Feb 29 hire celebrates on Mar 1 in years that have no Feb 29). Under 2 years: tier 0 (200 min/period). 2 to 4: tier 1 (260). 5 or more: tier 2 (320). Because accrual only happens on period starts, a new tier takes effect on the first period start that is on or after the anniversary. - Leave.
requestrecords a pending request for leave fromstarttoendinclusive.partis"full"or"half"; a half-day request must havestart == end. Checks:startandendmust be valid dates withend >= start,startstrictly after the clock, andstartnot before the employee's hire date, elseinvalid date; apartother thanfull/half, or a multi-dayhalf, isinvalid day part. A request costs nothing until it is approved. - Cost is fixed at
approvetime: each *working day* in the range (Monday to Friday, not a company holiday at that moment) costs 480 minutes, a half day costs 240. A request with no working days failsno working days. Holidays added later never change an approved request's cost. - The projection. Approval checks the *projected* balance at the leave's start. For an employee, the approved requests that have not started yet, together with the new one, are lined up in
(start date, request id)order. Each request in that list is judged oncurrent balance + credits the policy will post on days after the clock up to and including that request's start date - the costs of every request in the list up to and including it. If any of those values is below-960(16 hours) the approval fails withlimit exceededand nothing changes; exactly-960is allowed. The projection looks only at future *credits*; it does not know about holidays that have not been added yet. - Debit. On its start date (after that day's credits) an approved request's stored cost is posted as a
leaveentry with a negative amount, and the request is marked as started. - Cancel. A pending request becomes
cancelledand returns0. An approved request becomescancelled; if its cost had already been debited, arestoreentry for exactly the minutes that were debited is posted dated with the clock's current day and that amount is returned, otherwise0is returned and it simply stops counting against projections. Anything else isnot cancellable. historyentries are[date, kind, minutes]with kindsaccrual,grant,leave(negative),restoreandforfeit(negative).
Worked example
[["hire", "ana", "2021-01-02"],
["advance", "2026-01-16"],
["balance", "ana"],
["holiday", "2026-03-14"],
["holiday", "2026-03-16"],
["request", "r1", "ana", "2026-03-13", "2026-03-17", "full"],
["approve", "r1"],
["advance", "2026-03-13"],
["balance", "ana"],
["holiday", "2026-03-17"],
["cancel", "r1"],
["balance", "ana"]]returns [true, [], 580, true, true, true, 960, [], 580, true, 960, 1540]. Ana's fifth anniversary is 2026-01-02, so the Jan 1 credit is still the tier-1 260 and every later one is 320. r1 covers Fri 13th to Tue 17th; the 14th is a Saturday and the 16th is a Monday holiday, so only Friday and Tuesday are working days: 960. The 17th becoming a holiday afterwards changes nothing about what was debited.
Part 1 — Payroll audit findings (HR-2217)
Goal: make accruals, leave costs and cancellations follow the rules above. The audit found:
- "Two employees with the same hire date got different raises in accrual rate. One of them was moved to the next tier a few weeks before their anniversary."
- "My leave over a long weekend was charged one day too little, and the company holiday fell on a *Saturday*."
- "I cancelled a vacation after it was already deducted and was refunded a different number of hours than were deducted."
Find the causes yourself; tests/test_ledger.py has the matching failing tests. Hidden tests also cover the error order, the approval boundary (exactly -960), chained approvals in any order, and the cancel / holiday interactions.
Part 2 — Year-end carryover (HR-2240)
Goal: at the start of each new year, an employee may carry over only a capped balance; the rest is forfeited and reported.
Rules
- Rollover happens on January 1, as step (1) of that day: before any credit or leave debit of January 1.
- For each employee hired before that January 1 (in
emp_idorder, plain string comparison): the cap isCARRYOVER_CAPS[tier]wheretieris the tier in effect on that January 1. If the balance is greater than the cap, post aforfeitentry dated January 1 forbalance - cap. A balance at or below the cap, and any negative balance, is untouched (negative balances carry over as they are). advancereturns the forfeiture rows it produced,[year, emp_id, minutes], whereyearis the year that just ended, ordered by year and then byemp_id. If it crosses several new years, rows from all of them are returned. No forfeiture, no row.- A
restoreposted later is never forfeited retroactively; it just counts toward the next rollover. - The projection does not model forfeitures (it stays the optimistic sum described in Part 1).
Example (CARRYOVER_CAPS is 2400 / 2400 / 3600)
[["hire", "ana", "2015-01-05"], ["hire", "bo", "2025-06-10"],
["advance", "2026-12-31"], ["balance", "ana"], ["balance", "bo"],
["advance", "2027-01-01"], ["balance", "ana"], ["balance", "bo"]]returns [true, true, [], 7680, 4800, [[2026, "ana", 4080], [2026, "bo", 2400]], 3920, 2600]. On January 1 ana (tier 2, cap 3600) is cut from 7680 to 3600 and then receives that day's 320; bo (tier 0, cap 2400) is cut from 4800 to 2400 and then receives 200.
Part 3 — Front-loaded grants (HR-2301)
Goal: Finance is replacing per-period accrual with front-loaded annual grants. Add ["set_policy", "front_loaded", effective_date] and make the ledger, the projection and the year-end rollover follow the new policy. Requests already approved for future dates must be re-checked.
set_policy rules
- Errors in this order:
invalid policy(the name is not exactly"front_loaded"),policy already set(only one switch is ever allowed),invalid date(not a valid date, not a period start, or not strictly after the clock). - From
effective_dateEon, period starts no longer accrue. Days beforeEare unchanged. - Grants (
grantentries). Withannual(tier) = FRONT_LOADED_ANNUAL[tier]andmonths(D) = 12 - D.month, plus one ifDis the 1st of the month: a grant isannual(tier on D) * months(D) // 12(integer division). A grant of 0 is not recorded. A grant is posted, as part of step (2) of dayD, when
D == Efor every employee hired beforeE;Dis January 1 of any year afterE's year, for every employee hired beforeD;Dis the hire date, for an employee whose hire date isEor later. Mid-year tier changes therefore do not change the grant until the next January 1. Rollover (Part 2) still runs first on January 1, so the forfeit is taken before the new grant is added.
- The projection must include exactly the credits
advancewould post for that employee under whichever policy is in force, including grants dated on a hire date that is not a period start. - Re-validation. Immediately after a successful switch, for each employee in
emp_idorder, take their approved requests that have not started, in(start date, request id)order, and go through them once. Each is judged by the Part 1 projection rule against the *new* policy, counting only the requests that were kept earlier in this pass. A request that breaches-960is revoked (statusrevoked, it never debits, it is not cancellable) and does not count for the ones after it; otherwise it is kept. Pending requests and requests that have already started are untouched.set_policyreturns the ids of the revoked requests in the order they were revoked (employees inemp_idorder, then evaluation order).
| Operation | Arguments | Returns | Errors |
|---|---|---|---|
set_policy | "front_loaded", effective_date | list of revoked request ids | see rule 1 |
bulk_leave | emp_id, first_start, every_days, count, days | number of requests approved | invalid arguments, employee not found, invalid date |
bulk_leave is for importing a leave plan. It is exactly the sequence, for i = 0..count-1, of request then approve with request id "<emp_id>-b<i+1>", start first_start + i * every_days (calendar days), end start + days - 1, part "full". A request or approval that would be an error is skipped (a request that fails to approve stays pending, as it would with individual operations). every_days, count and days must be at least 1 (invalid arguments otherwise); first_start is checked like a request's start (valid, after the clock, not before the hire date, else invalid date). Hidden tests import hundreds of leaves for one employee spread over decades.
Example
[["hire", "bo", "2025-06-10"], ["advance", "2026-06-01"],
["request", "r1", "bo", "2026-09-07", "2026-09-11", "full"], ["approve", "r1"],
["request", "r2", "bo", "2026-10-05", "2026-10-08", "full"], ["approve", "r2"],
["request", "r3", "bo", "2026-12-21", "2026-12-23", "full"], ["approve", "r3"],
["set_policy", "front_loaded", "2026-07-16"], ["status", "r3"]]returns [true, [], true, 2400, true, 1920, true, 1440, ["r3"], "revoked"]: under accrual r3 fit exactly at -960; the front-loaded grant on 2026-07-16 is 4800 * 5 // 12 = 2000, 200 less than the 11 periods of accrual it replaces, so r3 now breaches the limit.
Notes
- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones; hidden tests re-check earlier behavior.
dates.pyhelpers are shared: be careful which behavior you change.
Example 1
- Input:
- [["hire","ana","2021-01-02"],["advance","2026-01-16"],["balance","ana"],["holiday","2026-03-14"],["holiday","2026-03-16"],["request","r1","ana","2026-03-13","2026-03-17","full"],["approve","r1"],["advance","2026-03-13"],["balance","ana"],["holiday","2026-03-17"],["cancel","r1"],["balance","ana"]]
- Output:
- [true,[],580,true,true,true,960,[],580,true,960,1540]
- Explanation
- Ana's 5th anniversary is 2026-01-02, so the 1 Jan credit is the tier-1 260 and later ones are 320. The request covers Fri 13 to Tue 17; the 14th is a Saturday and the 16th a holiday, so 2 working days cost 960. The 17th becoming a holiday afterwards does not change the refund.
Example 2
- Input:
- [["hire","ana","2015-01-05"],["hire","bo","2025-06-10"],["advance","2026-12-31"],["balance","ana"],["balance","bo"],["advance","2027-01-01"],["balance","ana"],["balance","bo"]]
- Output:
- [true,true,[],7680,4800,[[2026,"ana",4080],[2026,"bo",2400]],3920,2600]
- Explanation
- On 1 Jan the tier-2 cap of 3600 and the tier-0 cap of 2400 are applied before that day's credit, so the forfeits are 4080 and 2400.
Example 3
- Input:
- [["hire","bo","2025-06-10"],["advance","2026-06-01"],["request","r1","bo","2026-09-07","2026-09-11","full"],["approve","r1"],["request","r2","bo","2026-10-05","2026-10-08","full"],["approve","r2"],["request","r3","bo","2026-12-21","2026-12-23","full"],["approve","r3"],["set_policy","front_loaded","2026-07-16"],["status","r3"]]
- Output:
- [true,[],true,2400,true,1920,true,1440,["r3"],"revoked"]
- Explanation
- r3 fit exactly at -960 under accrual. The front-loaded grant on 2026-07-16 is 4800*5//12 = 2000, 200 less than the accrual it replaces, so r3 now breaches the limit and is revoked.
Guidelines
- Python 3 standard library only
- All durations are integer minutes; dates are YYYY-MM-DD strings and never include a time
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Output must be deterministic: nothing may depend on set or dict iteration order
- Part 3 inputs import hundreds of leave requests for one employee and advance the clock by decades