Civic Plaza Garage: New Tariff, Passes and Event Nights
EasyAI-assisted coding · AI-Assisted Feature Work
Civic Plaza Garage: New Tariff, Passes and Event Nights
You've joined the Parking Platform team that runs the ticketing backend for the Civic Plaza garage, a city-centre garage with ticket cars, monthly-pass holders and a rate card Finance changes a few times a year. Operations has an incident open, Finance has a new tariff waiting, and two more features are queued behind them. All money is integer cents. All times are integer seconds.
Codebase
| File | What it does |
|---|---|
garage/api.py | run(operations) entry point and the Dispatcher that routes each operation |
garage/service.py | GarageService: gate events, pay-station events, reads |
garage/pricing.py | parking_fee, the only place fees are computed |
garage/occupancy.py | Occupancy: how many vehicles are inside, whether the next one may enter |
garage/tickets.py | TicketBook: issues tickets, finds them by id or by plate |
garage/takings.py | Takings: money collected, for the end-of-shift report |
garage/models.py | Ticket dataclass and status constants |
garage/config.py | The rate card, ticket id format, validation codes, default capacity |
garage/errors.py | GarageError and every error code |
garage/checks.py | Argument checks shared by the services (known-good) |
garage/rates.py, garage/passes.py, garage/validations.py | Stubs for Parts 2 and 3 (their operations answer "not supported") |
tests/ | Existing unit tests (one of them currently fails) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh garage and returns one result per operation. Every operation is [name, ts, ...args]; ts is a non-negative integer and never decreases from one operation to the next. Errors are returned as strings ("error: <code>"), never raised. Plates, ticket ids, pass ids and codes are case-sensitive, non-blank strings; anything else for them returns "error: invalid arguments".
An unknown operation name returns "error: unknown operation". A wrong number of arguments, or a ts that is not a non-negative integer, returns "error: invalid arguments". When several errors apply, they are reported in the order they are listed in the tables below.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
set_capacity | n | true | invalid arguments (n must be an integer >= 1), capacity too small |
enter | plate | ticket id, e.g. "T-0001" | invalid arguments, already inside, garage full |
exit | ticket_id | the fee in cents | invalid arguments, ticket not found, ticket closed |
lost_ticket | plate | the lost-ticket fee | invalid arguments, vehicle not found |
quote | ticket_id | the fee exit would charge right now; changes nothing | same as exit |
free_spaces | none | spaces a ticket car can still take | |
revenue | none | cents collected so far |
- The default capacity is
DEFAULT_CAPACITYinconfig.py;set_capacitycan only be refused withcapacity too smallwhennis below the number of vehicles currently inside. - Ticket ids are
"T-0001","T-0002", ... in the order tickets are issued. A refusedenterdoes not use up an id. A plate with an open ticket is "inside"; once its ticket is closed (byexitorlost_ticket) the same plate may enter again with a new ticket. exitcloses the ticket, frees the space and adds the fee torevenue.lost_ticketfinds the plate's open ticket, closes it, frees the space and adds the lost-ticket fee torevenue.quotenever changes anything. A closed ticket answersticket closedtoexitandquote.
Part 1 — The new tariff, and the stuck FULL sign (FIN-88, OPS-412)
Goal: replace the 2019 "every started hour" fee in pricing.py with the new rate card, and fix the incident where the FULL sign stays lit after drivers use lost_ticket.
The new rate card (constants are in config.py). A stay runs from the ticket's ts at entry to the ts of the exit (or quote):
- Free time. The first 30 minutes (1 800 s) of every stay are free. A stay of 1 800 s or less costs 0.
- Started hours. After the free time, every started hour costs 400. The billable time therefore begins at
entry + 1800; one second of it costs a whole hour, 3 601 s cost two hours. - Daily maximum, per 24-hour block. Cut the stay into blocks of 86 400 s counted from entry: block 0 is
[entry, entry + 86400), block 1 is[entry + 86400, entry + 172800), and so on. Each block is priced on its own: the started hours of *the billable time that falls inside that block*, at 400 each, capped at 2 800. The fee is the sum over blocks. A stay that ends exactly on a block boundary does not open the next block. - Lost ticket.
lost_ticketcharges a flat 6 000 (LOST_TICKET_FEE_CENTS), whatever the stay was.
Worked example
[["set_capacity", 0, 2],
["enter", 36000, "KAB-123"],
["quote", 37800, "T-0001"],
["quote", 37801, "T-0001"],
["exit", 41401, "T-0001"],
["enter", 42000, "LMN-901"],
["lost_ticket", 42100, "LMN-901"],
["free_spaces", 42101],
["revenue", 42102]]returns [true, "T-0001", 0, 400, 800, "T-0002", 6000, 2, 6800]: 1 800 s in is still free, one second later costs an hour, and 5 401 s in leaves 3 601 billable seconds, so two hours. After the lost ticket both spaces are free again (that is the incident: today it answers 1).
Block example (entry at 1000)
[["enter", 1000, "CAP-1"],
["quote", 87400, "T-0001"],
["quote", 88401, "T-0001"],
["exit", 91000, "T-0001"]]returns ["T-0001", 2800, 3200, 3200]. At 87 400 (exactly 86 400 s in) the only block is block 0: its 84 600 billable seconds are 24 started hours = 9 600, capped at 2 800. At 88 401 block 1 holds 1 001 billable seconds, one more hour (400), total 3 200.
Abandoned cars really do sit for thousands of days; a stay must be priced without stepping through it second by second.
Part 2 — Merchant validations, monthly passes, reserved spaces (PAY-310)
Goal: shops stamp tickets for their customers, monthly-pass holders get their own gate with anti-passback, and some spaces are reserved for passes. Everything from Part 1 keeps working.
Validations
["validate", ts, ticket_id, code] attaches a merchant validation to an open ticket and returns how many validations the ticket now has. The codes are in config.py:
| Code | Effect |
|---|---|
STAMP30, STAMP60, STAMP120 | adds 30 / 60 / 120 free minutes |
CAFE3, CINEMA8 | takes 300 / 800 cents off the final fee |
Errors, in order: invalid arguments, ticket not found, ticket closed, unknown code, already validated (that code is already on the ticket), validation limit (a ticket holds at most 3).
The fee for a ticket is now computed in this order:
- Free time is 30 minutes plus all stamp minutes on the ticket. The billable time begins at
entry + free time. (Blocks are still counted from entry, so a stamp can push the start of the billable time into a later block.) - Price each block as in Part 1 (started hours, capped at 2 800 per block) and add the blocks up.
- Subtract the total of all coupons once, from that sum, and never go below 0.
Validations affect quote and exit. A lost_ticket is a flat 6 000 and ignores them.
Example (a car with a one-hour stamp and a CINEMA8 coupon, left 25 hours after entering)
[["enter", 100000, "SHOP-1"],
["validate", 100010, "T-0001", "STAMP60"],
["validate", 100020, "T-0001", "CINEMA8"],
["quote", 114400, "T-0001"],
["exit", 190000, "T-0001"]]returns ["T-0001", 1, 2, 400, 2400]. At 114 400 the stay is 14 400 s: free time 5 400 s, billable 9 000 s = 3 started hours = 1 200, minus the 800 coupon = 400. At 190 000 the stay is 90 000 s: block 0 has 81 000 billable seconds (23 started hours = 9 200, capped at 2 800), block 1 has 3 600 (one hour, 400), so 3 200 - 800 = 2 400.
Monthly passes
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
add_pass | pass_id | true | invalid arguments, pass exists |
enter_pass | pass_id | true | invalid arguments, pass not found, pass already inside, garage full |
exit_pass | pass_id | true | invalid arguments, pass not found, pass not inside |
pass_spaces | none | spaces a pass holder can still take |
Pass ids and plates are separate namespaces. Pass holders pay nothing, get no ticket, and never touch revenue. Anti-passback: a pass that is inside cannot enter again until it has exited (pass already inside), and a pass that is not inside cannot exit (pass not inside).
Reserved spaces
["set_reserved", ts, n] reserves n spaces for passes (initially 0) and returns true. Errors: invalid arguments (n must be an integer >= 0), reserved too large (n greater than the capacity). set_capacity now also answers capacity too small when n is below the number reserved, as well as when it is below the number of vehicles inside (ticket cars and passes).
Let C = capacity, R = reserved, T = ticket cars inside, P = passes inside. A pass holder uses a reserved space while one is free and spills into the public spaces after that, so spill = max(0, P - R). Then:
free_spaces(what the FULL sign is based on) =max(0, (C - R) - T - spill). A ticket car is refused withgarage fullexactly when this is 0.pass_spaces=C - T - P. A pass is refused withgarage fullexactly when this is 0.
With R = 0 and no passes this is the Part 1 behaviour (free_spaces = C - T).
Example
[["set_capacity", 0, 5], ["set_reserved", 0, 2],
["add_pass", 1, "P1"], ["add_pass", 1, "P2"], ["add_pass", 1, "P3"],
["enter_pass", 2, "P1"], ["enter_pass", 2, "P2"], ["enter_pass", 2, "P3"],
["free_spaces", 3], ["pass_spaces", 3],
["enter", 4, "A"], ["enter", 5, "B"], ["enter", 6, "C"],
["enter_pass", 7, "P1"], ["pass_spaces", 8],
["add_pass", 9, "P4"], ["enter_pass", 10, "P4"]]returns [true, true, true, true, true, true, true, true, 2, 2, "T-0001", "T-0002", "error: garage full", "error: pass already inside", 0, true, "error: garage full"]. The third pass spills into a public space, so ticket cars see 2 free spaces, not 3.
Part 3 — Event nights: a rate change that takes effect at a timestamp (FIN-102)
Goal: Finance wants to raise the hourly rate for a stadium event and put it back afterwards, without a deploy. The new rate takes effect at an exact time, so a stay that spans it must be priced piecewise.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
set_rate | effective_at, cents | true | invalid arguments (both integers >= 0), effective time in the past (effective_at < ts) |
rate_card | none | [[effective_at, cents], ...] sorted by time |
- The hourly rate in force at time
tis thecentsof the entry with the greatesteffective_at <= t.rate_cardalways starts with the base entry[0, 400](theHOURLY_RATE_CENTSconstant). set_ratewith aneffective_atthat already has an entry replaces that entry's rate. Entries may be added in any order ofeffective_at(as long as each is not in the past when added).- Each started hour is charged the rate in force at the instant that hour begins, never prorated. Hours begin where the billable time inside a block begins (
max(entry + free time, block start)) and follow every 3 600 s from there. Block starts, free time, the 2 800 daily maximum, stamps and coupons all work exactly as in Part 2; only the 400 per hour is replaced by the schedule. - The rate card applies to cars that are already inside:
quoteandexitalways use the schedule as it is when they are called. The rate printed on a ticket at entry (Ticket.hourly_rate) is no longer used for pricing. - A rate of
0is allowed (a free hour).
Example (base rate 400; a 1 000-per-hour event from 207 000 to 215 000)
[["enter", 200000, "EVT-1"],
["set_rate", 200100, 207000, 1000],
["set_rate", 200100, 215000, 400],
["rate_card", 200101],
["quote", 210800, "T-0001"],
["quote", 212600, "T-0001"],
["quote", 212601, "T-0001"]]returns ["T-0001", true, true, [[0, 400], [207000, 1000], [215000, 400]], 1800, 1800, 2800]. The billable time starts at 201 800, so hours begin at 201 800, 205 400, 209 000, 212 600. The hour that begins at 205 400 runs across the 207 000 change but is charged 400. At 210 800 three hours have begun (400 + 400 + 1 000). The fourth begins at 212 600 and is charged 1 000, so one second after that the fee is 2 800 (which is also the daily maximum).
Notes
checks.py,takings.pyandtickets.pyare known-good.- 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 after each Part.
- Hidden tests include stays of thousands of days, and a rate card with about a hundred entries.
Example 1
- Input:
- [["set_capacity",0,2],["enter",36000,"KAB-123"],["quote",37800,"T-0001"],["quote",37801,"T-0001"],["exit",41401,"T-0001"],["enter",42000,"LMN-901"],["lost_ticket",42100,"LMN-901"],["free_spaces",42101],["revenue",42102]]
- Output:
- [true,"T-0001",0,400,800,"T-0002",6000,2,6800]
- Explanation
- The first 30 minutes are free; one second later costs a started hour; 5401 seconds in leaves 3601 billable seconds, so two hours. The lost ticket costs a flat 60.00 and frees its space.
Example 2
- Input:
- [["enter",1000,"CAP-1"],["quote",87400,"T-0001"],["quote",88401,"T-0001"],["exit",91000,"T-0001"]]
- Output:
- ["T-0001",2800,3200,3200]
- Explanation
- Block 0 (the first 24 hours from entry) is capped at 28.00. The 1001 billable seconds that fall in block 1 add a single started hour, 4.00.
Example 3
- Input:
- [["enter",200000,"EVT-1"],["set_rate",200100,207000,1000],["set_rate",200100,215000,400],["rate_card",200101],["quote",210800,"T-0001"],["quote",212600,"T-0001"],["quote",212601,"T-0001"]]
- Output:
- ["T-0001",true,true,[[0,400],[207000,1000],[215000,400]],1800,1800,2800]
- Explanation
- Each started hour is charged the rate in force when it begins: the hour that straddles the 207000 change still costs 4.00, the hour beginning at 209000 costs 10.00.
Guidelines
- Python 3 standard library only
- Money is integer cents and time is integer seconds; there is no floating-point arithmetic in any fee
- Timestamps are non-negative integers and never decrease from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Hidden tests include stays of thousands of days and a rate card with about a hundred entries