Wash & Fold: Pricing, Pickup Lockers and Subscriptions
EasyAI-assisted coding · AI-Assisted Debugging
Wash & Fold: Pricing, Pickup Lockers and Subscriptions
You've joined the small team behind the counter software for a wash-and-fold laundry chain. Customers drop off a bag, it gets weighed in grams, and later the finished order is put in a pickup locker. Two things are happening this sprint: customers are complaining that prices at the counter don't match the price list (Part 1), and the new pickup lockers need software behind them (Part 2). After that, a subscription plan lands and changes some of the rules you just built (Part 3). All money is integer cents.
Codebase
| File | What it does |
|---|---|
wash/api.py | run(operations) entry point and the Dispatcher that routes each operation |
wash/service.py | LaundryService: quotes, drop-off, status, and the stubs for Parts 2 and 3 |
wash/pricing.py | Pounds from grams, tier charges, rush detection, price_order |
wash/lockers.py | LockerBank and the pickup-code generator make_pickup_code |
wash/subscriptions.py | Placeholder for Part 3 |
wash/rules.py | Argument validation helpers |
wash/models.py | Order, Price and the order-state constants |
wash/timeutil.py | Timestamp parsing and arithmetic helpers |
wash/errors.py | ServiceError and every error code |
wash/config.py | All price-list and locker constants |
tests/ | Existing tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh store and returns one result per operation. Every operation is [name, ts, ...args]. Timestamps are strings shaped exactly "YYYY-MM-DD HH:MM" on one local clock (no time zones, no daylight saving) and never decrease from one operation to the next. A malformed or impossible timestamp (including in ready_by) returns "error: invalid arguments". Errors are returned as "error: <code>" strings, never raised.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
quote | grams, ready_by | price object | invalid weight, invalid ready time |
drop_off | order_id, customer, grams, ready_by | price object plus "order" | invalid weight, invalid ready time, order exists |
status | order_id | {"state", "locker", "due"} | order not found |
A price object is {"pounds", "covered", "base", "rush", "total"}; total = base + rush. drop_off returns the same object with an "order" key (the order id) added in front.
- An unknown operation name returns
"error: unknown operation"; a wrong number of arguments returns"error: invalid arguments", as does a non-string or emptyorder_id/customer, and agramsvalue that is not an integer (JSON booleans are not integers). - A valid weight is an integer from 1 to 50,000 grams.
ready_bymust be strictly later thants. statusreturnsstate(dropped,in_locker,at_deskorcollected),locker(a label such as"L03"while the order is in a locker, otherwisenull) anddue(cents still owed on the order: its total plus any re-delivery fee).
Part 1: Make counter prices match the price list
Finance has pinned down the price list below. Make quote and drop_off follow it exactly. The quote operation is the sticker price a customer is shown; drop_off prices and records the order.
- Billable pounds are the weight in grams rounded up to the next whole pound, where 1 lb = 453.59237 g exactly. In integers:
pounds = ceil(grams * 100000 / 45359237). (4535 g is 10 lb; 4536 g is 11 lb.) - Laundry charge: the first 10 lb of an order cost 199 cents per lb; every pound beyond 10 costs 149 cents per lb.
- Minimum charge: the laundry charge is never below 1500 cents. The result of this step is
base. - Rush: an order is a rush order when
ready_byis at most 24 hours afterts(exactly 24:00 counts as rush; 24:01 does not). This is a real elapsed-time comparison, so it must hold across day, month and year boundaries. - Rush fee: for a rush order,
rushis 25% ofbase(the charge *after* the minimum is applied), rounded half up to a whole cent. Otherwiserushis 0. coveredis always 0 for now (Part 3 uses it).
Worked example
[["quote", "2025-03-03 09:00", 5000, "2025-03-05 09:00"],
["quote", "2025-03-03 09:00", 1000, "2025-03-04 09:00"],
["drop_off", "2025-03-03 09:05", "o-1", "ana", 4536, "2025-03-04 09:06"],
["status", "2025-03-03 09:10", "o-1"]]returns
[{"pounds": 12, "covered": 0, "base": 2288, "rush": 0, "total": 2288},
{"pounds": 3, "covered": 0, "base": 1500, "rush": 375, "total": 1875},
{"order": "o-1", "pounds": 11, "covered": 0, "base": 2139, "rush": 0, "total": 2139},
{"state": "dropped", "locker": null, "due": 2139}]5000 g is 12 lb (10 x 199 + 2 x 149). 1000 g is 3 lb = 597 cents, lifted to the 1500 minimum, and the order is wanted in exactly 24 hours, so it is rush: 25% of 1500 = 375. The third order is 11 lb and its 09:06 next-day deadline is 24h01 away, so no rush.
Part 2: Pickup lockers
Finished orders go into pickup lockers. Build the staff operations below. Orders are collected from a locker with a 6-digit code; codes expire, and an expired order is moved to the front desk for a re-delivery fee.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
mark_ready | order_id | {"locker", "code", "expires_at"} | order not found, invalid state, no free locker |
pickup | order_id, code | {"order", "locker", "paid"} | order not found, code expired, invalid state, wrong code |
desk_pickup | order_id | {"order", "paid"} | order not found, invalid state |
- Placing an order.
mark_readyputs adroppedorder into the lowest-numbered free locker (8 lockers, shown asL01...L08) and sets its state toin_locker. Only adroppedorder can be placed, otherwiseinvalid state. If every locker is taken the order staysdroppedand the error isno free locker. - Codes. The code is
make_pickup_code(order_id, locker_number, ts)fromwash/lockers.py(already written; do not change it). It is returned as a string and printed on the receipt. - Expiry. A code is valid for 72 hours: it stops working at exactly
placed + 72:00.expires_atis that moment as a timestamp. - Moving to the front desk. Expiry must be noticed by every operation, whatever it is: before any operation is handled, every order whose expiry time is at or before that operation's
tsmoves to the desk: its state becomesat_desk, its locker is freed (and may be handed out by that same operation), and it owes a re-delivery fee of 300 cents. - Collecting.
pickupwith the right code from a locker returns the locker label andpaid(the order total); the order becomescollectedand its locker is free again. A wrong code (any value that is not the order's code, including a non-string) iswrong code. An order that isat_deskanswerscode expiredwhatever code is given; an order that isdroppedor alreadycollectedanswersinvalid state. - Front desk.
desk_pickupworks only on anat_deskorder (anything else isinvalid state), returnspaid= total + re-delivery fee, and marks the ordercollected.statusreportsdueas total + fee for anat_deskorder.
Worked example
[["drop_off", "2025-03-03 09:00", "o-1", "ana", 5000, "2025-03-04 09:00"],
["drop_off", "2025-03-03 09:10", "o-2", "bo", 4536, "2025-03-05 09:00"],
["mark_ready", "2025-03-03 18:00", "o-1"],
["mark_ready", "2025-03-03 18:30", "o-2"],
["pickup", "2025-03-04 10:00", "o-1", "111111"],
["pickup", "2025-03-04 10:01", "o-1", "599854"],
["status", "2025-03-06 18:29", "o-2"],
["status", "2025-03-06 18:30", "o-2"],
["pickup", "2025-03-06 18:31", "o-2", "728340"],
["desk_pickup", "2025-03-07 08:00", "o-2"]]returns (after the two drop_off results, which are the usual price objects)
[{"locker": "L01", "code": "599854", "expires_at": "2025-03-06 18:00"},
{"locker": "L02", "code": "728340", "expires_at": "2025-03-06 18:30"},
"error: wrong code",
{"order": "o-1", "locker": "L01", "paid": 2860},
{"state": "in_locker", "locker": "L02", "due": 2139},
{"state": "at_desk", "locker": null, "due": 2439},
"error: code expired",
{"order": "o-2", "paid": 2439}]o-2 expires at 18:30 on 03-06: one minute earlier it is still in its locker, and at 18:30 the status call itself finds it already at the desk with the 300-cent fee added.
Part 3: Subscriptions (requirements change)
Marketing is launching a subscription plan. A subscriber has a monthly allowance of pounds that pays for laundry before any money is charged. This replaces two earlier rules, so revisit Parts 1 and 2 as well as adding the new operations.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
subscribe | customer, monthly_pounds | true | invalid allowance, subscription exists |
allowance | customer | pounds currently available (int) | not subscribed |
monthly_pounds must be an integer from 1 to 200 (a non-integer or boolean is invalid arguments; any integer outside the range is invalid allowance).
- Months. Allowances follow calendar months (
2025-03-31 23:59and2025-04-01 00:00are different months).subscribesets the customer's available pounds tomonthly_poundsfor the month of itsts; no earlier allowance exists to roll over. - Renewal and rollover. At each month boundary the available pounds become
monthly_pounds + min(10, leftover): at most 10 unused pounds roll over. This applies once for every month that passes, including months with no activity (leftover 4 with a 20 lb plan gives 24 after one month, then 30 after a second). Renewal is evaluated lazily, at the month of thetsof the operation that looks at the allowance (drop_offorallowance). - Using the allowance. At
drop_off, the customer's available pounds (after renewal) are applied to the order:covered = min(pounds, available). Covered pounds are removed from the allowance. The allowance pays for pounds at the cheaper rate first: covered pounds are taken from the tier-2 pounds (the pounds beyond the first 10), and only when those are all covered does it cover tier-1 pounds. The laundry charge is then the per-pound rates applied to the uncovered pounds only.quoteignores subscriptions entirely (it is the sticker price). - Minimum charge (changes Part 1). The 1500-cent minimum now applies only when at least one pound of the order is uncovered. A fully covered order has
base0. Everything else in Part 1 is unchanged: the rush fee is still 25% ofbase(so a fully covered rush order has no rush fee). - Re-delivery (changes Part 2). The 300-cent re-delivery fee is waived (0) for a subscriber. "Subscriber" is decided at the moment the order moves to the desk: that happens at the start of the first operation whose
tsis at or after expiry, *before* that operation runs (so asubscribeat exactly the expiry minute is too late for that order). - A rejected
drop_off(any error) must not consume any allowance.
Worked example
[["subscribe", "2025-03-01 08:00", "ana", 20],
["drop_off", "2025-03-03 09:00", "o-1", "ana", 7000, "2025-03-05 09:00"],
["allowance", "2025-03-03 09:01", "ana"],
["drop_off", "2025-03-04 09:00", "o-2", "ana", 5000, "2025-03-06 09:00"],
["allowance", "2025-03-04 09:01", "ana"],
["allowance", "2025-04-02 12:00", "ana"],
["quote", "2025-04-02 12:00", 7000, "2025-04-04 12:00"]]returns
[true,
{"order": "o-1", "pounds": 16, "covered": 16, "base": 0, "rush": 0, "total": 0},
4,
{"order": "o-2", "pounds": 12, "covered": 4, "base": 1592, "rush": 0, "total": 1592},
0,
20,
{"pounds": 16, "covered": 0, "base": 2884, "rush": 0, "total": 2884}]o-1 (16 lb) is fully covered by the 20 lb allowance, leaving 4. o-2 is 12 lb: 2 tier-2 pounds and 10 tier-1 pounds. The 4 remaining allowance pounds cover both tier-2 pounds first, then 2 tier-1 pounds, leaving 8 tier-1 pounds = 1592 (above the minimum). The allowance is then 0, and renews to 20 + min(10, 0) in April.
Example 1
- Input:
- [["quote","2025-03-03 09:00",1000,"2025-03-04 09:00"],["drop_off","2025-03-03 09:05","o-1","ana",4536,"2025-03-04 09:06"]]
- Output:
- [{"pounds":3,"covered":0,"base":1500,"rush":375,"total":1875},{"order":"o-1","pounds":11,"covered":0,"base":2139,"rush":0,"total":2139}]
- Explanation
- 1000 g is 3 lb (597 cents) lifted to the 1500 minimum, and a deadline exactly 24h away is rush: 25% of 1500 is 375. 4536 g is 11 lb: 10 x 199 + 149, and 24h01 is not rush.
Example 2
- Input:
- [drop_off o-2 (4536 g), mark_ready at 2025-03-03 18:30, status at 2025-03-06 18:29, status at 2025-03-06 18:30]
- Output:
- [..., {"locker":"L02","code":"728340","expires_at":"2025-03-06 18:30"}, {"state":"in_locker","locker":"L02","due":2139}, {"state":"at_desk","locker":null,"due":2439}]
- Explanation
- The code expires exactly 72 hours after placement; the status call at the expiry minute finds the order already at the front desk with the 300-cent re-delivery fee.
Example 3
- Input:
- [subscribe ana 20, drop_off o-1 (7000 g), allowance, drop_off o-2 (5000 g), allowance, allowance in April]
- Output:
- [true, {..."covered":16,"total":0}, 4, {..."covered":4,"base":1592,"total":1592}, 0, 20]
- Explanation
- The 20 lb allowance fully covers the 16 lb order, then covers the 2 tier-2 pounds and 2 tier-1 pounds of the 12 lb order; it renews to 20 in April.
Guidelines
- Python 3 standard library only
- Money is integer cents and weight is converted with integer arithmetic; there is no floating point in pricing
- Timestamps are strings shaped "YYYY-MM-DD HH:MM" on a single local clock and never decrease from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised