Rental Kiosk: Nightly Cutoffs, Sales and Subscriptions
EasyAI-assisted coding · AI-Assisted Debugging
Rental Kiosk: Nightly Cutoffs, Sales and Subscriptions
You've joined the team behind ReelBox, a chain of DVD and game rental kiosks (airport, harbor, midtown). Customers rent a disc at one kiosk and bring it back to any kiosk. Billing is per night, and a "night" ends at 9:00 PM, not after 24 hours. Support has escalated two billing bugs (customers are being charged for a night they didn't use), and two features are queued behind them. All money is integer cents. Timestamps are "YYYY-MM-DD HH:MM" strings in kiosk local time.
Codebase
| File | What it does |
|---|---|
kiosk/api.py | run(operations) entry point and the Dispatcher that routes each operation |
kiosk/service.py | KioskService: stock, renting, returning, status, account summary |
kiosk/clock.py | Timestamp parsing and the nightly cutoff arithmetic (business day, night cutoffs, night count) |
kiosk/pricing.py | Nightly rate, sale price and the rental charge |
kiosk/inventory.py | Inventory: every disc and where it is |
kiosk/accounts.py | AccountBook: per-customer charges and discs currently out |
kiosk/promos.py | Placeholder for Part 2 |
kiosk/models.py, kiosk/errors.py, kiosk/config.py | Records, error codes, catalog and chain constants |
tests/ | Existing unit and 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 chain and returns one result per operation. Every operation is [name, ts, ...args]. Timestamps never decrease from one operation to the next. Errors are returned as "error: <code>" strings, never raised.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
add_disc | disc_id, title, kiosk | true | disc exists, unknown title, unknown kiosk |
shelf | kiosk | sorted list of disc ids on that kiosk's shelf | unknown kiosk |
rent | account_id, disc_id | true | unknown disc, disc unavailable |
return | disc_id, kiosk | the charge for this rental, in cents | unknown disc, not rented, wrong kiosk |
status | disc_id | a status object (below) | unknown disc |
account | account_id | {"charged", "out", "promo_used", "subscribed"} | none |
- An unknown operation name returns
"error: unknown operation". A malformed timestamp, a wrong number of arguments or an argument that is not a non-empty string returns"error: invalid arguments". - Titles and their prices are in
kiosk/config.py. Accounts exist implicitly: any account id is valid. statusreturns{"state": "shelf", "kiosk": k}for a disc on a shelf and{"state": "out", "account": a, "nights": n, "cutoff": "YYYY-MM-DD 21:00"}for a disc that is out (nightsandcutoffdescribe what a return at that moment would bill).accountreturns the totalchargedso far, the sorted ids of the discs the account hasout, and two flags that later Parts fill in (promo_used,subscribed; bothfalsefor now).- For now a disc may only be returned to the kiosk it came from (
wrong kioskotherwise). Part 2 lifts that.
Part 1: Fix the night count
A rental's business day is the calendar day it is rented on, except that a rental made at 21:00 or later belongs to the *next* calendar day. Night 1 ends at 21:00 on the day after the business day, night 2 at 21:00 the day after that, and so on. A disc returned at time t is billed for the smallest night whose cutoff is at or after t. A return at exactly 21:00 is on time. One minute later bills the next night. Every rental is billed at least one night, even if it comes back within the hour. The charge is nights x nightly rate.
The two customer reports ("charged an extra night for returning at 9:00 sharp", "rented right at 9 PM and got billed like I rented before 9") both come from the cutoff arithmetic. Fix the codebase so the rules above hold, including across month, leap-day and year boundaries. Do not change other behaviour.
Worked example (arrival costs 300/night, heat 250/night)
[["add_disc", "2025-03-10 08:00", "d1", "arrival", "midtown"],
["add_disc", "2025-03-10 08:00", "d2", "heat", "midtown"],
["rent", "2025-03-10 20:59", "ana", "d1"],
["rent", "2025-03-10 21:00", "ben", "d2"],
["status", "2025-03-11 21:00", "d1"],
["status", "2025-03-11 21:01", "d1"],
["return", "2025-03-12 21:00", "d1", "midtown"],
["return", "2025-03-12 21:00", "d2", "midtown"]]returns
[true, true, true, true,
{"state": "out", "account": "ana", "nights": 1, "cutoff": "2025-03-11 21:00"},
{"state": "out", "account": "ana", "nights": 2, "cutoff": "2025-03-12 21:00"},
600, 250]ana rented at 20:59, so her business day is 03-10 and night 1 ended 03-11 21:00. ben rented at 21:00 sharp, so his business day is 03-11 and his night 1 ends 03-12 21:00.
Part 2: Sales, any-kiosk returns and a promo code
Customers keep forgetting discs, so after enough nights the disc becomes theirs. Return boxes at every kiosk also have to work. Add all of the following.
- Return anywhere.
returnaccepts any kiosk in the chain. The disc goes on the shelf of the kiosk it was returned to (shelfandstatusreflect that). Thewrong kioskerror no longer exists. Errors forreturnare now checked in this order:unknown disc,unknown kiosk,disc sold,not rented. - Charge cap. A return never costs more than the title's sale price: the charge is
min(sale price, nights x nightly rate). - Sale conversion. A rental that is still out *after* the cutoff of its 5th night (a time strictly later than that cutoff) converts to a sale. Returning at exactly the 5th cutoff is still a normal, on-time return. At the conversion the account is charged the title's sale price in full (this replaces the rental charge) and the disc is gone for good: its status is
{"state": "sold", "account": a, "sold_at": "<5th cutoff>", "charge": <sale price>}, it is on no shelf, it is no longer in the account'soutlist, andrenton it returnsdisc unavailable. Conversion is a matter of time, not of anybody touching the disc: it must show up in every operation that happens later, whether or not the disc itself is ever mentioned again. Areturnof a sold disc returns"error: disc sold"and changes nothing. - Promo code.
renttakes an optional third argument, a promo code.NIGHTONEmakes the first night free: the charge becomesmin(sale price, max(0, nights - 1) x nightly rate). Each account can use a code once; that use is recorded when the rental is created (even if the disc is later sold), andaccountreports"promo_used": trueafter any promo use. Extrarenterrors, checked afterdisc unavailable:unknown promo(code not recognised), thenpromo already used. A rejected rental never uses up the code. A sold promo rental still costs the full sale price.
Worked example
[["add_disc", "2025-03-10 08:00", "d1", "heat", "midtown"],
["add_disc", "2025-03-10 08:00", "d2", "arrival", "airport"],
["rent", "2025-03-10 10:00", "ana", "d1"],
["rent", "2025-03-10 10:00", "ben", "d2", "NIGHTONE"],
["return", "2025-03-15 21:00", "d2", "harbor"],
["shelf", "2025-03-15 21:00", "harbor"],
["status", "2025-03-15 21:01", "d1"],
["return", "2025-03-15 21:01", "d1", "midtown"],
["account", "2025-03-15 21:01", "ana"]]returns
[true, true, true, true, 1200, ["d2"],
{"state": "sold", "account": "ana", "sold_at": "2025-03-15 21:00", "charge": 1200},
"error: disc sold",
{"charged": 1200, "out": [], "promo_used": false, "subscribed": false}]Ben's 5 nights with the free first night come to 4 x 300 = 1200 (under the 1400 sale price) and d2 lands on the harbor shelf. Ana would have owed 5 x 250 = 1250 for d1, which the 1200 sale price caps. She didn't return it by 21:00 on the 15th, so one minute later it is sold to her for 1200.
Part 3: Requirements change: rental limit and subscriptions
Finance found accounts hoarding discs, and marketing is launching ReelBox Plus. Two changes, and one of them reaches back into Part 2.
- Limit. An account may have at most 2 discs out at a time.
rentreturns"error: rental limit"when the account already has 2 out. The updatedrenterror order is:unknown disc,disc unavailable,rental limit,unknown promo,promo already used. A disc counts as out until it is returned or sold, so a sale frees the slot exactly when it happens (never earlier or later). - Subscription. A new operation
subscribe(account_id) returnstrueand makes that account a subscriber from that moment on (there is no cancellation); a secondsubscribefor the same account returns"error: already subscribed". Theaccountflagsubscribedbecomestrue. A rental made by a subscriber has its first night free and converts to a sale after the 7th night instead of the 5th (the cutoff, the status and the charges all follow the 7th night). Those terms are fixed when the rental is created: subscribing later does not change a disc that is already out, and a subscriber's old terms never come back because there is no cancellation. - Promos and subscriptions. Only one first night is ever free. A subscriber's promo code is still validated (
unknown promo/promo already usedapply), but it is not used up and does not stack, since the subscription already covers that night.
Worked example
[["add_disc", "2025-03-10 08:00", "d1", "up", "midtown"],
["add_disc", "2025-03-10 08:00", "d2", "up", "midtown"],
["add_disc", "2025-03-10 08:00", "d3", "arrival", "midtown"],
["rent", "2025-03-10 09:00", "ana", "d1"],
["rent", "2025-03-10 09:00", "ana", "d2"],
["rent", "2025-03-10 09:00", "ana", "d3"],
["rent", "2025-03-15 21:00", "ana", "d3"],
["rent", "2025-03-15 21:01", "ana", "d3"],
["subscribe", "2025-03-15 21:02", "ben"],
["account", "2025-03-15 21:02", "ana"]]returns
[true, true, true, true, true, "error: rental limit", "error: rental limit", true, true,
{"charged": 2000, "out": ["d3"], "promo_used": false, "subscribed": false}]Both up discs (sale price 1000) are still out at exactly 21:00 on the 15th, so the limit still applies; a minute later both have been sold, which frees both slots, and d3 can be rented.
Example 1
- Input:
- [["add_disc","2025-03-10 08:00","d1","arrival","midtown"],["rent","2025-03-10 20:59","ana","d1"],["status","2025-03-11 21:00","d1"],["status","2025-03-11 21:01","d1"],["return","2025-03-12 21:00","d1","midtown"]]
- Output:
- [true,true,{"state":"out","account":"ana","nights":1,"cutoff":"2025-03-11 21:00"},{"state":"out","account":"ana","nights":2,"cutoff":"2025-03-12 21:00"},600]
- Explanation
- Rented at 20:59, so night 1 ends at 21:00 the next day. A minute past that is night 2, and returning at exactly the night-2 cutoff is on time: 2 x 300.
Example 2
- Input:
- [["add_disc","2025-03-10 08:00","d1","heat","midtown"],["rent","2025-03-10 10:00","ana","d1"],["status","2025-03-15 21:01","d1"],["return","2025-03-15 21:01","d1","midtown"]]
- Output:
- [true,true,{"state":"sold","account":"ana","sold_at":"2025-03-15 21:00","charge":1200},"error: disc sold"]
- Explanation
- One minute after the 5th cutoff the disc is sold to the renter for the full sale price, and a later return is refused.
Example 3
- Input:
- [["add_disc","2025-03-10 08:00","d1","up","midtown"],["add_disc","2025-03-10 08:00","d2","up","midtown"],["add_disc","2025-03-10 08:00","d3","arrival","midtown"],["rent","2025-03-10 09:00","ana","d1"],["rent","2025-03-10 09:00","ana","d2"],["rent","2025-03-10 09:00","ana","d3"]]
- Output:
- [true,true,true,true,true,"error: rental limit"]
- Explanation
- Part 3: an account may have at most 2 discs out at a time.
Guidelines
- Python 3 standard library only
- Amounts are integer cents; there is no floating-point money anywhere
- Timestamps are "YYYY-MM-DD HH:MM" strings and never decrease from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- A return at exactly a 9:00 PM cutoff is on time; anything later, even by a minute, is the next night