Shift Pay: Overtime, Meals, Sites and Closed Weeks
HardAI-assisted coding · AI-Assisted Feature Work
Shift Pay: Overtime, Meals, Sites and Closed Weeks
You've joined the payroll platform team at Harbor & Pine Hospitality, which runs a few dozen restaurants and bars. Shift Pay turns clock punches into the weekly pay report that the payroll provider imports. Finance has just finished an audit (AUD-0412) that found three pay errors, and the roadmap behind it is long: meal penalties, a new Reno site with different overtime law, and corrections to weeks that have already been paid. All money is integer cents.
Codebase
| File | What it does |
|---|---|
payroll/api.py | run(operations) entry point and the Dispatcher that validates and routes each operation |
payroll/punches.py | PunchLog: clock-in / break / clock-out state machine; builds Shifts |
payroll/segments.py | Turns shifts into the paid Segments of one workweek (breaks removed, split by day and week) |
payroll/overtime.py | Classifies paid minutes as regular / overtime / double time (daily pass, then weekly pass) |
payroll/differential.py | Night differential: minutes of work inside the nightly window |
payroll/payslips.py | PayEngine.report: turns classified minutes into pay lines |
payroll/meals.py | Meal-period compliance (stub until Part 2) |
payroll/rates.py | RateBook: hourly rate per employee and role, dated by workweek |
payroll/roster.py | Roster: who works here |
payroll/workweek.py | Calendar: which workday and workweek a minute belongs to |
payroll/money.py | Rounding convention (div_round_half_up) and cent-minute units |
payroll/config.py | Company-wide constants: thresholds, night window, meal rules |
payroll/models.py | Shift, Segment, Piece and the regular / overtime / double-time constants |
payroll/errors.py | PayrollError and every error code |
payroll/policy.py, payroll/period.py | Empty placeholders for Parts 3 and 4 |
tests/ | Plain-assert unit tests (two of them currently fail) |
Read workweek.py, money.py and config.py first: the calendar, the rounding convention and the thresholds below are defined there, and the README only summarises them.
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh service and returns one result per operation. Each operation is [name, *args]. Errors are returned as "error: <code>" strings, never raised.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
configure | week_start, workday_start | true | invalid arguments, locked |
hire | employee | true | invalid arguments, employee exists |
set_rate | employee, role, cents_per_hour, from_week | true | invalid arguments, unknown employee |
punch | employee, kind, minute (+ role when kind is "in") | true | invalid arguments, unknown employee, out of order, bad sequence, no rate |
pay_report | employee, week | a report (below) | invalid arguments, unknown employee |
- A non-list or empty operation returns
"error: invalid arguments"; a name that is not a known operation (including a non-string) returns"error: unknown operation"; a wrong number of arguments or an argument of the wrong shape returns"error: invalid arguments", always before any other check. Integers must be real integers (trueis not one). Employee ids, roles and sites are non-empty strings. configure:week_startis a weekday0(Monday) to6,workday_starta minute of day0..1439. It returnslockedonce any shift exists (any clock-in has been punched).set_rate:cents_per_hour > 0,from_week >= 0. A rate applies to every week>= from_weekuntil a later entry for the same employee and role takes over; setting the samefrom_weekagain replaces it.punch:kindis"in","out","break_start"or"break_end";minute >= 0;roleis given for"in"and only then. An employee is *off*, *working* or *on a break*:inis valid only when off,outandbreak_startonly when working,break_endonly when on a break (anything else isbad sequence).out of ordermeans the minute is earlier than that employee's previous punch (equal is fine, so zero-length shifts and breaks exist).no ratemeans the employee has no rate for that role in force in the week of the clock-in. A failed operation changes nothing.
Time
Time is an integer number of minutes since Monday 00:00, site-local (no daylight saving). A workday does not start at midnight but at workday_start (04:00 by default), so 03:59 still belongs to the previous workday. Workweek n is the seven workdays starting at workday 7n - 7 + week_start; with the defaults, week 1 is days 0-6 (Monday 04:00 to the next Monday 04:00) and week 0 is the stub before it. In examples, minute = day * 1440 + hh * 60 + mm with day 0 the first Monday.
Pay rules
Pay is computed per employee per workweek, from paid minutes:
- A shift's paid time is its clock-in to clock-out time minus its breaks (today every break is unpaid; Part 2 changes that). A shift that is still open (no clock-out yet) is not paid.
- Paid minutes belong to the workday and workweek they fall in by the clock. A shift that runs across a workday or workweek boundary is split there.
- *Daily pass.* Within one workday, the first 480 paid minutes are regular, the next 240 (up to 720) are overtime, anything after that is double time. All shifts and roles in the workday count together.
- *Weekly pass.* Going through the week in time order, regular minutes beyond the first 2400 become overtime. Only regular minutes count toward the 2400: an hour already paid as daily overtime or double time is not counted a second time.
- *Night differential.* Every paid minute inside the 22:00-06:00 clock window (it wraps midnight; 22:00 is inside, 06:00 is outside) earns 150 cents per hour on top of its rate.
- *Rates.* Each minute earns straight time at the rate of its shift's role in force in the week the minute falls in. Overtime and double-time minutes earn an extra premium, 0.5 and 1.0 times the week's regular rate: all straight-time pay of the week, night differential included, divided by all paid minutes of the week. (With two roles at different rates the regular rate is the minute-weighted blend, not an average of the rates.)
Money. Pay is computed in cent-minutes (rate * minutes; 60 make a cent). Every line is rounded on its own, half up, to a whole cent; the regular rate itself is never rounded; gross is the sum of the rounded lines.
Report. pay_report returns {"closed": false, "minutes": [regular, overtime, double_time], "lines": [...], "gross": cents}, where minutes are the week's paid minutes by class. Lines, in this order, each omitted if its quantity is 0:
| Line | Meaning |
|---|---|
["straight", role, minutes, cents] | straight time, one per role, role ascending |
["night", null, minutes, cents] | night differential |
["overtime", null, minutes, cents] | overtime premium |
["double_time", null, minutes, cents] | double-time premium |
A week with no paid work returns {"closed": false, "minutes": [0, 0, 0], "lines": [], "gross": 0}.
Worked example (Dana, line cook at $20.00/h, rate from week 0; Monday 09:00-17:30 with a 13:00-13:30 break):
[["hire", "dana"], ["set_rate", "dana", "line_cook", 2000, 0],
["punch", "dana", "in", 540, "line_cook"], ["punch", "dana", "break_start", 780],
["punch", "dana", "break_end", 810], ["punch", "dana", "out", 1050], ["pay_report", "dana", 1]]The report is {"closed": false, "minutes": [480, 0, 0], "lines": [["straight", "line_cook", 480, 16000]], "gross": 16000}.
Part 1: Audit findings (AUD-0412)
Goal: Finance hand-computed the right pay for three employees whose reports were wrong. Find the causes in the code and fix them so that all three match, without special-casing. The three findings are independent. (Every shift below that is longer than five hours has a 30-minute lunch that starts within its first five hours; Part 2 starts charging for shifts that don't.)
- Dana, overnight closer. Tuesday 22:00 to Wednesday 07:00 (break 01:00-01:30), then Wednesday 12:00-20:00 (break 15:00-15:30). She is paid only 30 minutes of overtime. Her Wednesday workday holds 3 h of the overnight shift plus 7.5 h of the afternoon shift, so Finance expects 150 minutes. Expected report: minutes
[810, 150, 0], linesstraight line_cook 960 -> 32000,night 450 -> 1125,overtime 150 -> 2588, gross 35713. - Marco, server at $15.00/h. Monday to Friday 09:00-19:30 (break 13:00-13:30) and Saturday 09:00-13:00. His report shows 22 hours of overtime and $975.00; Finance says 14 hours: ten daily-overtime hours plus the four Saturday hours (he already had 40 regular hours). Expected: minutes
[2400, 840, 0], linesstraight server 3240 -> 81000,overtime 840 -> 10500, gross 91500. - Priya, bartender 16.00/h. Friday host 10:00-14:00, then bartender 17:00 to Saturday 02:00 (break 20:00-20:30). Her overtime and double-time premiums are too high. Expected: minutes
[480, 240, 30], linesstraight bartender 510 -> 18700,straight host 240 -> 6400,night 240 -> 600,overtime 240 -> 4112,double_time 30 -> 1028, gross 30840.
tests/test_calendar.py and tests/test_overtime.py each contain a currently failing test that points at one cause.
Part 2: Meal periods and rest breaks (PAY-1331)
Goal: Oregon law is coming to every site. Pay rest breaks, and pay a penalty hour for missed meals.
Rules
- *Rest breaks.* A break shorter than 20 minutes is a paid rest break: it stays in the shift's paid time. A break of 20 minutes or more is unpaid, as today.
- *Meal period.* A shift whose elapsed time (clock-out minus clock-in, breaks included) is more than 300 minutes must contain a *meal*: a break of at least 30 minutes that starts at most 300 minutes after clock-in. Whether the break is paid or unpaid is irrelevant to this check; a 25-minute break (unpaid) and a 15-minute break (paid) are both too short.
- A shift without a meal is owed one penalty hour (never more than one per shift), paid at the shift's role rate in force in the week the shift started in, and reported in that week even if the shift ends in the next week.
- A penalty is pay, not work: it adds no paid minutes, no overtime, no differential and is not part of the regular rate. It appears as
["meal_penalty", role, hours, cents], one line per role (role ascending), after the other lines. - Part 1 behaviour is unchanged otherwise.
Example (Elena, server at $15.00/h): Wednesday 10:00-16:30 with a break 13:00-13:25; Thursday 09:00-13:00; Friday 09:00-15:00 with a break 11:00-11:15. Wednesday's 25-minute break is unpaid (365 paid minutes) and too short for a meal; Thursday is under 300 minutes; Friday's 15-minute break is paid (360 paid minutes) and too short. Report: minutes [965, 0, 0], lines straight server 965 -> 24125, meal_penalty server 2 -> 3000, gross 27125.
Part 3: Sites with their own overtime rules (PAY-1388)
Goal: The Reno opening has no daily overtime, and other sites will differ in more ways. Overtime thresholds, the night window and differential, and the meal penalty become per-site policy.
| Operation | Arguments | Returns | Errors (in order) |
|---|---|---|---|
site | site_id, settings | true | invalid arguments, site exists |
assign | employee, site_id, from_week | true | invalid arguments, unknown employee, unknown site |
Rules
settingsis an object that may contain any of:daily_ot_after,daily_dt_after,weekly_ot_after(eachnullor an integer> 0),night_start,night_end(minutes of day0..1439),night_diff(integer>= 0),meal_penalty(boolean). Missing fields take the values fromconfig.py, not from another site. Any other key, a wrong type,daily_dt_afterset whiledaily_ot_afterisnull, ordaily_dt_after <= daily_ot_afterisinvalid arguments.nullremoves a band: nodaily_ot_aftermeans no daily overtime or double time; nodaily_dt_aftermeans overtime but no double time; noweekly_ot_aftermeans no weekly overtime.night_start == night_endmeans there is no night window. A line is still reported when its differential is 0 cents.- The built-in site
mainalways exists with exactly the Part 1-2 settings; defining it again issite exists. Employees belong tomainuntil assigned. assignapplies from workweekfrom_weekonward until a later assignment; the samefrom_weekagain replaces. The policy of a week applies to every minute in that week (so a shift across a week boundary uses each week's policy for its own minutes), and the meal-penalty switch of the week the shift started in decides the penalty.- Reports always reflect the current settings and assignments.
Example. ["site", "reno", {"daily_ot_after": null, "daily_dt_after": null}], then ["assign", "sam", "reno", 1]. Sam and Tom (both cooks at $20.00/h, Tom on main) work Monday to Thursday 08:00-18:30 with a 12:00-12:30 break (600 paid minutes each day) and Friday 09:00-13:00. Sam: minutes [2400, 240, 0], straight cook 2640 -> 88000, overtime 240 -> 4000, gross 92000. Tom: minutes [2160, 480, 0] (daily overtime), overtime 480 -> 8000, gross 96000.
Part 4: Closing weeks, corrections and the next pay run (PAY-1402)
Goal: Payroll closes a week when it is paid. Punches are corrected after the fact, and a paid week must never be rewritten: the difference is paid in the next run as an adjustment. A season has dozens of closed weeks and queries must stay fast.
| Operation | Arguments | Returns | Errors (in order) |
|---|---|---|---|
close_week | week | true | invalid arguments, already closed, open shift |
edit_punch | employee, kind, old_minute, new_minute | true | invalid arguments, unknown employee, no such punch, bad sequence, no rate |
add_shift | employee, role, start, end, breaks | true | invalid arguments, unknown employee, no rate, overlap |
bulk_shifts | employee, role, first_in, length, every, count, break_offset, break_len | count | invalid arguments, unknown employee, out of order, bad sequence, no rate |
payroll | week | [total_gross, headcount] | invalid arguments |
Corrections
edit_punchmoves the earliest punch ofkindwhose minute isold_minutetonew_minute(no such punchif there is none). The employee's whole punch sequence must stay in order (equal minutes allowed), elsebad sequence. Moving a clock-in needs a rate for the role in the new week (no rate). Open shifts can be edited; weeks already closed can be edited.add_shiftinserts a missed, completed shift:start <= end,breaksa list of[start, end]pairs withstart <= b1.start <= b1.end <= b2.start <= ... <= end(anything else isinvalid arguments). It conflicts (overlap) with an existing shift whenstart < other.end and end > other.start(an open shift extends forever), so shifts may touch. Later live punches are still checked against the employee's latest punch minute (out of order), added shifts included.bulk_shiftsappendscount(1..20000) shifts oflengthminutes whose clock-ins areeveryminutes apart starting atfirst_in, each with one break ofbreak_lenminutes startingbreak_offsetafter clock-in (none ifbreak_lenis 0). Needslength >= 0,1 <= every,length <= every,break_offset >= 0,break_len >= 0,break_offset + break_len <= length. It is checked like a clock-in atfirst_in, and is all or nothing.
Closing and adjustments
close_week(n)closes every week up tonthat is not yet closed (already closedifnis closed already). It fails withopen shift, changing nothing, if any employee is still clocked in (working or on a break) from a week<= n.- A closed week's report is frozen:
closedistrueand its lines and gross never change again. A closed week with no lines reports{"closed": true, "minutes": [0, 0, 0], "lines": [], "gross": 0}. - For each employee and closed week Shift Pay remembers what has been paid to date: the week's worked minutes and gross as computed when it was closed, updated whenever an adjustment is paid. Whenever data changes (a punch, a correction,
set_rate,assign) the same computation as Parts 1-3 gives each week's current worked minutes and gross, the "current value". - The report of week
closed_through + 1(the next run) ends with one line per closed week whose current value differs from paid to date:["adjustment", week, minutes_difference, cents_difference](current minus paid, minutes being regular + overtime + double time), weeks ascending. Adjustment lines are part ofgross;minutesstill describes only the report's own week. No other week's report carries adjustments. When that week is closed, its frozen report keeps the adjustment lines and paid to date catches up to the current value. set_rate(withfrom_week) andassignalso change the current value of closed weeks, so they produce adjustments the same way.payroll(week)returns[total_gross, headcount]over all employees, where headcount is the number of employees whose report for that week has at least one line (adjustment-only employees count).closedisfalsein the reports of open weeks.
Example (after Dana's Part 1 example): ["close_week", 1], then ["edit_punch", "dana", "out", 1050, 1110] (she actually stayed until 18:30). Week 1 still reports gross 16000 with closed true. pay_report dana 2 returns lines [["adjustment", 1, 60, 3000]] (540 paid minutes would now be 480 regular + 60 overtime), gross 3000; week 3 has no adjustment line. After ["close_week", 2] week 2 is frozen with that line. If the punch is then moved to 1140, pay_report dana 3 shows ["adjustment", 1, 30, 1500], because only the difference to what was paid is owed.
Scale. Hidden tests close a season of dozens of weeks for a dozen employees (several thousand shifts), then interleave late corrections and retroactive rate changes with many payroll calls for the next run. Re-deriving every closed employee-week for each query is too slow.
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 behaviour (including under sites and after weeks are closed).
- Parts 1-3 are a strong result. Part 4 is the stretch.
Example 1
- Input:
- [["hire","dana"],["set_rate","dana","line_cook",2000,0],["punch","dana","in",2760,"line_cook"],["punch","dana","break_start",2940],["punch","dana","break_end",2970],["punch","dana","out",3300],["punch","dana","in",3600,"line_cook"],["punch","dana","break_start",3780],["punch","dana","break_end",3810],["punch","dana","out",4080],["pay_report","dana",1]]
- Output:
- [true,true,true,true,true,true,true,true,true,true,{"closed":false,"minutes":[810,150,0],"lines":[["straight","line_cook",960,32000],["night",null,450,1125],["overtime",null,150,2588]],"gross":35713}]
- Explanation
- Finding 1: the overnight shift is split at the 04:00 workday boundary, so Wednesday's workday holds 3 h of it plus the 7.5 h afternoon shift (150 overtime minutes); night differential applies to 450 paid minutes.
Example 2
- Input:
- [["hire","elena"],["set_rate","elena","server",1500,0],["punch","elena","in",3480,"server"],["punch","elena","break_start",3660],["punch","elena","break_end",3685],["punch","elena","out",3870],["punch","elena","in",4860,"server"],["punch","elena","out",5100],["punch","elena","in",6300,"server"],["punch","elena","break_start",6420],["punch","elena","break_end",6435],["punch","elena","out",6660],["pay_report","elena",1]]
- Output:
- [true,true,true,true,true,true,true,true,true,true,true,true,{"closed":false,"minutes":[965,0,0],"lines":[["straight","server",965,24125],["meal_penalty","server",2,3000]],"gross":27125}]
- Explanation
- Wednesday's 25-minute break is unpaid but not a meal, Thursday is short enough to need no meal, and Friday's 15-minute break is paid but too short: two penalty hours at $15.00.
Example 3
- Input:
- [["hire","dana"],["set_rate","dana","line_cook",2000,0],["punch","dana","in",540,"line_cook"],["punch","dana","break_start",780],["punch","dana","break_end",810],["punch","dana","out",1050],["close_week",1],["edit_punch","dana","out",1050,1110],["pay_report","dana",1],["pay_report","dana",2],["payroll",2]]
- Output:
- [true,true,true,true,true,true,true,true,{"closed":true,"minutes":[480,0,0],"lines":[["straight","line_cook",480,16000]],"gross":16000},{"closed":false,"minutes":[0,0,0],"lines":[["adjustment",1,60,3000]],"gross":3000},[3000,1]]
- Explanation
- After week 1 is closed, correcting the clock-out to 18:30 does not change week 1; the difference (60 more paid minutes, $30.00) is an adjustment line in the next run, week 2.
Guidelines
- Python 3 standard library only
- Money is integer cents; every pay line is rounded half up on its own (see payroll/money.py), no floating point
- Time is integer minutes since Monday 00:00; punch minutes never decrease for one employee
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Part 4 inputs close a season of dozens of weeks for a dozen employees (several thousand shifts) and then interleave corrections with many payroll queries