Bean & Bloom Rewards: Earn, Expire, Refund, Tier and Transfer
MediumAI-assisted coding · AI-Assisted Feature Work
Bean & Bloom Rewards: Earn, Expire, Refund, Tier and Transfer
You've joined the Loyalty Platform team at Bean & Bloom, a coffee chain with a points program. Members earn points on purchases, spend them on rewards, and lose them if they sit on them too long. The backend works for simple cases, but Support has escalated three wrong-balance tickets, and product has three features queued behind them (refunds, automatic tiers, household transfers). Points are always integers; money is integer cents; one day is 86 400 seconds.
Codebase
| File | What it does |
|---|---|
loyalty/api.py | run(operations) entry point and the Dispatcher that routes each operation |
loyalty/service.py | LoyaltyService: enrolling, promotions, purchases (earning), redeeming, balances |
loyalty/wallet.py | PointsWallet: one member's points, stored as dated buckets |
loyalty/models.py | Bucket, Member, Purchase, Redemption |
loyalty/earn.py | compute_earn: base points and promotional bonus for a purchase |
loyalty/rounding.py | The rounding rule for fractional points |
loyalty/promos.py | PromoBook: promotional multiplier windows |
loyalty/tiers.py | Tier ladder helpers (rate_for, tier_for_spend, higher_tier) |
loyalty/members.py | MemberDirectory: enrol and look up members |
loyalty/validation.py | Argument-shape checks used by every operation |
loyalty/config.py | Program constants: expiry lifetimes, rates, thresholds, reward catalogue |
loyalty/errors.py | LoyaltyError and every error code |
loyalty/refunds.py, loyalty/households.py | Placeholders for Parts 2 and 4 (their routes answer "error: not supported") |
tests/test_service.py | Existing unit tests (two of them currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh program and returns one result per operation. Every operation is [name, ts, ...args]; ts is an integer number of seconds and never decreases from one operation to the next. Errors are returned as "error: <code>" strings, never raised.
Checks run in the order listed for each operation, and the first failure is the result. Two general rules: an unknown operation name returns "error: unknown operation" (before anything else); a wrong number of arguments or a ts that is not an integer returns "error: invalid arguments". Inside an operation, an id (member, purchase, promo, batch, redemption, household), a reward code or a tier name that is not a non-empty string, or a number argument that is not an integer, is also invalid arguments and is checked first. true/false are never integers. Two exceptions have their own codes, listed with the operations: a bad amount (invalid amount) and bad points (invalid points).
Part 1 operations
| Operation | Arguments after ts | Returns | Errors (in order) |
|---|---|---|---|
enroll | member_id, household_id, tier | true | invalid tier, member exists |
add_promo | promo_id, start_ts, end_ts, percent | true | invalid promo, promo exists |
purchase | member_id, purchase_id, amount | points earned | invalid amount, member not found, purchase exists |
redeem | member_id, reward | "rd-1", "rd-2", ... | member not found, unknown reward, insufficient points |
balance | member_id | points usable now | member not found |
tieris one of"base","silver","gold".amountis cents and must be an integer> 0.add_promo:percentmust be in101..1000andstart_ts < end_ts, elseinvalid promo. A promotion applies to a purchase whose timetsatisfiesstart_ts <= t < end_ts. Several promotions in force at once do not stack: the highestpercentwins. A promotion only exists from the moment it is added.- Reward costs are in
loyalty/config.py(coffee150,pastry220,sandwich400,mug900). Redemption ids count up with each successful redemption.
How earning works (the code in earn.py and config.py is canonical):
- The earn rate comes from the member's tier:
base10,silver15,gold20, in thousandths of a point per cent (so base earns 1 point per dollar). exact = amount * rate. Base points =exact / 1000rounded to the nearest integer, halves rounded up (4.5 becomes 5; 4.4 becomes 4). All rounding in the program lives inloyalty/rounding.py.- If a promotion with percent
Pis in force at the purchase's time, bonus points =exact * (P - 100) / 100 / 1000(computed from the exact, unrounded earn), rounded half up on its own. Base and bonus are rounded separately and then added; the purchase returnsbase + bonus. - Base points go into a bucket that expires
180days after the purchase; bonus points go into their own bucket that expires30days after it. A bucket of 0 points is not created (but the purchase still exists).
How points are held and spent:
- A bucket is usable while
now < expires_at. At exactlyexpires_atit is already gone (that is true forbalance, forredeem, and everywhere else). balanceis the sum of the unspent points in usable buckets.redeemneeds at least the reward's cost.- A redemption spends from usable buckets in this order: soonest
expires_atfirst, then earliest earned time, then the bucket that was created first. A bucket may be spent partly.
Worked example
[["enroll", 0, "ana", "h1", "base"],
["add_promo", 1, "launch", 0, 2592000, 300],
["purchase", 100, "ana", "p1", 10000],
["purchase", 200, "ana", "p2", 10000],
["redeem", 300, "ana", "coffee"],
["balance", 2592099, "ana"],
["balance", 2592100, "ana"]]returns [true, true, 300, 300, "rd-1", 450, 400]. Each purchase earns 100 base points plus a 200-point bonus (30 days). The coffee (150) is paid from p1's bonus bucket because it expires first (day 30 + 100 s), leaving it
- At second 2592099 everything is still there (450); at 2592100 p1's bonus bucket has just expired (400).
Part 1: three wrong-balance tickets (LOY-3127, LOY-3140, LOY-3152)
Goal: make earning, expiry and redemption follow the rules above. Support's reports:
- "I spent $4.50 and got 4 points. Our terms say halves round up. The promo bonus looks off in the same way."
- "I redeemed a free coffee, then watched a pile of bonus points expire a week later. The app spent my long-lived points first."
- "My promo bonus was still in my balance at the exact second it was supposed to expire."
tests/test_service.py has two failing tests that cover some of this; do not assume they cover everything. Where a rule is implemented is part of the job: fix the cause, not each symptom.
Part 2: refunds and cancelling a redemption (LOY-3188)
Goal: add refund and cancel_redemption. Both live in loyalty/refunds.py.
| Operation | Arguments after ts | Returns | Errors (in order) |
|---|---|---|---|
refund | purchase_id | member's balance after the refund | purchase not found, already refunded |
cancel_redemption | redemption_id | member's balance after the cancel | redemption not found, already cancelled, cancel window closed, redemption touched by refund |
Refund rules. A refund reverses the points earned on that purchase (its base bucket and its bonus bucket, if any). Points are never recomputed; the purchase's actual buckets are what is reversed. For each of the two buckets, with consumed = points originally credited - points still unspent in it:
- Whatever is still unspent in the purchase's buckets is removed. If a bucket has already expired, its unspent points are simply forgiven (nothing is taken for them).
- The consumed points (spent on rewards) must still be given back: the total of
consumedover the purchase's buckets is taken from the member's other usable buckets, soonestexpires_atfirst (same order as a redemption), as far as they go. - Whatever cannot be covered becomes debt, a number on the member that never expires.
balanceisusable points - debt, so it can be negative. Any points a member gets later (a purchase, a restore, a transfer) raise the balance and so work the debt off; nothing else is needed to settle it. - A redemption needs
balance >= cost(debt included), so a member in debt can be refused even though their usable buckets would cover the reward. Spending only ever takes points from usable buckets.
Cancel rules. A redemption can be cancelled while ts < redeemed_ts + 86400; at exactly that second the window is closed. Cancelling puts back the exact amounts into the exact buckets the redemption spent from, so each bucket keeps its original expires_at. If one of those buckets has expired by the time of the cancel, the points are put back but are no longer usable. redemption touched by refund means a bucket the redemption spent from was reversed by a refund (the points can no longer be restored). Cancelling does not use up a redemption id and the redemption keeps its id.
Worked examples
[["enroll", 0, "ana", "h1", "base"],
["purchase", 100, "ana", "p1", 10000],
["purchase", 200, "ana", "p2", 4000],
["purchase", 300, "ana", "p3", 2000],
["redeem", 400, "ana", "coffee"],
["refund", 500, "p1"],
["purchase", 600, "ana", "p4", 3000]]returns [true, 100, 40, 20, "rd-1", -90, 30]. The coffee (150) is paid from p1 (100), p2 (40) and p3 (10). Refunding p1 must claw back 100 points, all already spent, so the member's other usable buckets are used (p3's remaining 10) and the remaining 90 is debt: the refund returns a balance of 0 - 90 = -90. The next purchase earns 30, which brings the balance to -60.
[["enroll", 0, "ana", "h1", "base"],
["purchase", 100, "ana", "p1", 10000],
["purchase", 200, "ana", "p2", 10000],
["redeem", 300, "ana", "coffee"],
["cancel_redemption", 400, "rd-1"],
["balance", 15552100, "ana"],
["cancel_redemption", 401, "rd-1"]]returns [true, 100, 100, "rd-1", 200, 100, "error: already cancelled"]. p1's bucket expires at 100 + 180 days = 15552100: the cancel restored its points *into p1's bucket*, so they expire on schedule and the balance at 15552100 is 100 (p2's), not 200.
Part 3: tiers are earned, not assigned (LOY-3201)
Goal: a member's tier now follows their spending. This replaces the "tier is whatever it was at enrolment" behaviour of Parts 1 and 2.
| Operation | Arguments after ts | Returns | Errors |
|---|---|---|---|
tier | member_id | [tier, qualifying_spend] | member not found |
Rules
- Qualifying spend at time
tis the totalamount(cents) of the member's purchases made at timespwitht - 365 days < p <= t, excluding refunded purchases. A purchase stops counting atp + 365 daysexactly. A refund removes the purchase from the spend immediately (at the refund'sts), whatever tier the purchase earned at; refunding a purchase that has already left the 365-day window changes nothing. - The tier from spend is
goldat spend>= 150000,silverat>= 50000, otherwisebase(tiers.tier_for_spend). - The tier given to
enrollbecomes the member's minimum tier: a member's current tier is the higher of that and the tier from spend. - A purchase earns at the member's tier as of just before that purchase: the purchase's own amount does not count toward its own tier, but earlier purchases at the same
tsdo. - Tier changes affect only future purchases: points already earned (and refunds of them) are never recomputed.
tierreturns[current tier, qualifying spend]as ofts.
Worked example
[["enroll", 0, "ana", "h1", "base"],
["purchase", 1, "ana", "p1", 49000],
["purchase", 2, "ana", "p2", 1000],
["tier", 3, "ana"],
["purchase", 4, "ana", "p3", 1000],
["refund", 5, "p1"],
["tier", 6, "ana"],
["purchase", 7, "ana", "p4", 1000]]returns [true, 490, 10, ["silver", 50000], 15, 25, ["base", 2000], 10]. p2 (the purchase that reached $500) still earned at the base rate; p3 earned at silver. Refunding p1 pulls the member back to base: p1's 490 points are removed (balance 25), and p4 earns at the base rate again.
Part 4: household transfers and a year of history (LOY-3230)
Goal: add household point transfers, then make the balance and expiry queries fast enough for a program-wide nightly job over a year of daily purchases.
| Operation | Arguments after ts | Returns | Errors (in order) |
|---|---|---|---|
transfer_points | from_id, to_id, points | sender's balance after | invalid points, same member, member not found, different household, insufficient points |
bulk_purchases | batch_id, member_ids, first_ts, every, count, base_amount, spread | total points earned | invalid amount, member not found, invalid batch, purchase exists |
expiring_soon | member_id, days | [points, earliest_expiry] | member not found |
expiry_digest | days, min_points | [members, points] | none |
total_liability | (none) | total live points | none |
transfer_points. points must be an integer > 0 (else invalid points); member not found checks the sender first. Both members must have the same household_id. The sender needs balance >= points (debt counts, as for a redemption). The points are spent from the sender's usable buckets in the usual order, and for every bucket that gives points the receiver gets a new bucket with the same amount, the same earned_ts and the same expires_at (transfers preserve expiry; they never reset it). Transferred buckets are not tied to any purchase, so a later refund never reverses them. A refund of a purchase whose points were transferred out sees those points as consumed. Transfers do not affect tier spend.
expiring_soon. days must be an integer >= 1 (else invalid arguments). points is the unspent points in the member's usable buckets whose expires_at <= ts + days * 86400 (a bucket expiring exactly at the horizon counts); earliest_expiry is the smallest such expires_at, or null when points is 0. Debt is ignored here. expiry_digest (both arguments integers >= 1, else invalid arguments) counts the members whose expiring_soon points for that days are >= min_points, and totals those points. total_liability is the sum over all members of their usable points (debt is not netted).
bulk_purchases replays a quiet stretch of regular purchases for a cohort without a giant operation list. member_ids is a non-empty list of ids (shape problems are invalid arguments; duplicates are invalid batch); first_ts, every, count, spread must be integers and spread >= 1 (else invalid arguments); base_amount must be an integer > 0 (else invalid amount); every < 1 or count < 1 is invalid batch. For occurrence i = 0 .. count-1, at time first_ts + i * every, the member at list position j makes a purchase of base_amount + (i * 37 + j * 101) % spread cents with purchase id "<batch_id>:<member_id>:<i>". Each one behaves exactly like a purchase at that time (tier, promotions, buckets, spend), processed in time order and in list order within a time. The operation returns the total points earned. It is rejected with invalid batch unless every occurrence time lies between the largest integer ts of any earlier operation in the list (0 if none; an earlier operation counts even if it failed, was unknown or had the wrong number of arguments, as long as its ts is an integer) and this operation's own ts, both inclusive; and purchase exists if any generated purchase id is already used. A rejected batch changes nothing.
Worked example
[["enroll", 0, "ana", "h1", "base"],
["enroll", 0, "bo", "h1", "base"],
["enroll", 0, "cy", "h2", "base"],
["purchase", 100, "ana", "p1", 10000],
["purchase", 200, "ana", "p2", 20000],
["transfer_points", 300, "ana", "bo", 150],
["transfer_points", 301, "ana", "cy", 10],
["expiring_soon", 15552000, "bo", 1],
["expiring_soon", 15552100, "bo", 1]]returns [true, true, true, 100, 200, 150, "error: different household", [150, 15552100], [50, 15552200]]. Ana's 100 points from p1 and 50 of p2's moved to Bo in two buckets that keep expiry dates 15552100 and
- At 15552100 the first bucket is gone.
Scale. Hidden tests run tens of thousands of purchases through bulk_purchases and hundreds of expiry_digest, expiring_soon and total_liability calls in one run; the whole suite has a few seconds. Looking at all of a member's history on each query is too slow.
Notes
earn.py,promos.py,members.py,validation.pyandconfig.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. Where a later Part changes a rule, the later rule wins and says so.
- Parts 1-3 is a strong result. Part 4 is a stretch.
Example 1
- Input:
- [["enroll",0,"ana","h1","base"],["add_promo",1,"launch",0,2592000,300],["purchase",100,"ana","p1",10000],["purchase",200,"ana","p2",10000],["redeem",300,"ana","coffee"],["balance",2592099,"ana"],["balance",2592100,"ana"]]
- Output:
- [true,true,300,300,"rd-1",450,400]
- Explanation
- Each purchase earns 100 base points plus a 200-point bonus that expires after 30 days. The coffee is paid from the bonus bucket that expires first. One second before day 30 + 100s the balance is 450; at that second the first bonus bucket is gone (400).
Example 2
- Input:
- [["enroll",0,"ana","h1","base"],["purchase",100,"ana","p1",10000],["purchase",200,"ana","p2",4000],["purchase",300,"ana","p3",2000],["redeem",400,"ana","coffee"],["refund",500,"p1"],["purchase",600,"ana","p4",3000]]
- Output:
- [true,100,40,20,"rd-1",-90,30]
- Explanation
- The coffee spent p1's 100 points, p2's 40 and 10 of p3's. Refunding p1 has to claw back 100 already-spent points: p3's remaining 10 are taken and the other 90 become debt, so the balance is -90.
Example 3
- Input:
- [["enroll",0,"ana","h1","base"],["enroll",0,"bo","h1","base"],["purchase",100,"ana","p1",10000],["purchase",200,"ana","p2",20000],["transfer_points",300,"ana","bo",150],["expiring_soon",15552000,"bo",1],["expiring_soon",15552100,"bo",1]]
- Output:
- [true,true,100,200,150,[150,15552100],[50,15552200]]
- Explanation
- Ana moves 100 points from p1 and 50 from p2 to Bo. Bo's two new buckets keep the original expiry dates (180 days after each purchase), so one day later the first of them has expired.
Guidelines
- Python 3 standard library only
- Money is integer cents and points are integers; there is no floating-point arithmetic in earning (rounding lives in loyalty/rounding.py)
- Timestamps are integer seconds and never decrease from one operation to the next (bulk_purchases replays the quiet period before its own ts)
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Part 4 inputs replay tens of thousands of purchases and run hundreds of balance/expiry queries in one run