Subscription Billing: Upgrades, Coupons and Credits
MediumAI-assisted coding · AI-Assisted Debugging
Subscription Billing: Upgrades, Coupons and Credits
You've joined the billing team at a small SaaS company. Customers subscribe to a plan, are billed every 30 days, and can change plan or cancel at any time. Finance has flagged that upgrade charges are wrong, and the roadmap has two features queued behind that fix. All amounts are integer cents, and days are integer day numbers.
Codebase
| File | What it does |
|---|---|
billing/api.py | run(operations) entry point and the Dispatcher that routes each operation |
billing/subscriptions.py | SubscriptionService: subscribe, change plan, cancel, (Part 3) invoice a cycle |
billing/proration.py | Billing-cycle arithmetic: cycle_start, days_remaining, prorate |
billing/invoicing.py | InvoiceService: issues and stores invoices, spends account credit on them |
billing/coupons.py | CouponBook stub for Part 2 |
billing/credits.py | CreditLedger stub for Part 3 (balances are always zero today) |
billing/catalog.py, billing/config.py | The plan catalog (basic 1999, pro 4999, team 9999 cents per cycle) and CYCLE_DAYS = 30 |
billing/models.py, billing/errors.py | Dataclasses and every error code |
tests/ | Existing tests (some of them currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh billing system and returns one result per operation. Every operation is [name, day, ...args]; day is an integer and never decreases. Errors are returned as "error: <code>" strings, never raised.
| Operation | Arguments after day | Returns | Errors (checked in this order) |
|---|---|---|---|
subscribe | customer, plan_id or customer, plan_id, coupon_code | subscription id ("sub-1", "sub-2", ...) | unknown plan, already subscribed, then coupon errors (Part 2) |
change_plan | sub_id, plan_id | {"charged": int, "credited": int, "invoice": id or null} | subscription not found, subscription cancelled, unknown plan, same plan |
cancel | sub_id | true | subscription not found, subscription cancelled |
invoice | invoice_id | {"id", "customer", "day", "lines": [[description, cents], ...], "total"} | invoice not found |
invoices | customer | that customer's invoice ids, oldest first ([] if none) | none |
create_coupon | code, kind, value, max_redemptions, expires_day | true | Part 2 |
credit_balance | customer | the customer's account credit in cents (0 if none) | none |
invoice_cycle | sub_id, cycle | invoice id | Part 3 |
- An unknown operation name returns
"error: unknown operation". A wrong number or type of arguments (or a non-integerday) returns"error: invalid arguments". Booleans are not integers. - A customer has at most one non-cancelled subscription at a time; after
cancelthey may subscribe again. Subscription ids and invoice ids are allocated in order of success; a failed operation never uses one up. - Cycles. A subscription started on day
sis billed in cycles of 30 days: cyclencovers dayss + 30nthroughs + 30n + 29. Cycle 0 is invoiced when the customer subscribes, with the line"<plan> plan, cycle 0"for the plan's price. The *current cycle* on daydis the one containingd, whether or not it has been invoiced. - Plan changes take effect immediately. For an upgrade the customer is charged for the days left in the current cycle, where
days left = (first day of the next cycle) - d. On the first day of a cycle that is 30; on its last day it is 1. The charge is(new price - old price) * days left / 30, rounded to the nearest cent once, on the total (halves round up), and appears as one invoice line"upgrade <old> to <new>, <days left> days".chargedin the result is that amount andinvoicethe id of the invoice that carries it.
Worked example
[["subscribe", 0, "ann", "basic"],
["change_plan", 10, "sub-1", "team"],
["invoices", 10, "ann"],
["invoice", 10, "inv-2"]]returns
["sub-1",
{"charged": 5333, "credited": 0, "invoice": "inv-2"},
["inv-1", "inv-2"],
{"id": "inv-2", "customer": "ann", "day": 10,
"lines": [["upgrade basic to team, 20 days", 5333]], "total": 5333}](8000 * 20 / 30 = 5333.33.)
Part 1 — Upgrade charges are wrong
Goal. Customers upgrading mid-cycle are charged slightly the wrong amount, and one customer reports an upgrade after cancelling. Find and fix the causes.
Rules
- Upgrade proration follows the formula above: the right number of days left, and rounding applied once to the whole amount rather than per day.
- A cancelled subscription cannot change plan:
error: subscription cancelled(checked right aftersubscription not foundand before the plan is looked at). - An upgrade whose prorated charge rounds to 0 creates no invoice (
"invoice": null). - Everything else in the table above already works.
Part 2 — Coupons
Goal. Marketing wants limited-use discount codes that customers redeem when they subscribe.
Rules
create_coupon(code, kind, value, max_redemptions, expires_day)withkind"percent"(value1-100) or"fixed"(value>= 1 cents).max_redemptionsisnull(unlimited) or an integer >= 1;expires_dayisnullor an integer. Returnstrue. A bad kind/value/limit iserror: invalid coupon; a code that already exists iserror: coupon exists(checked after the first). Codes are case-sensitive.subscribe(customer, plan, code)adds a second line to the cycle 0 invoice,"coupon <code>", with a negative amount, so the invoice total is the price minus the discount. A percent discount isprice * value / 100rounded to the nearest cent once, halves rounding up; a fixed discount isvalue, but never more than the price. The discount applies to that first invoice only; no later invoice is discounted.- Coupon errors are checked in this order, after
unknown planandalready subscribed:unknown coupon;coupon expired(the coupon can be used onexpires_dayitself, and not after);coupon already redeemed(this customer has used this code before, even on a subscription they cancelled);coupon exhausted(max_redemptionsredemptions already used). - A redemption is used up only when the subscription is actually created. Any failed
subscribeleaves the coupon's remaining count untouched.
Example. A 10% coupon on basic: 1999 * 10 / 100 = 199.9, so the discount is 200 and the invoice lines are [["basic plan, cycle 0", 1999], ["coupon SAVE10", -200]] with total 1799.
Part 3 — Downgrades, account credit and idempotent cycle invoices
Goal. Support downgrades that give credit for the unused time, spend that credit on later invoices, and let the nightly billing job invoice renewals safely even when it retries.
Rules
- A
change_planto a cheaper plan credits the customer instead of charging:credited = (old price - new price) * days left / 30, rounded to the nearest cent once, halves rounding up. The result is{"charged": 0, "credited": <amount>, "invoice": null}; the plan changes immediately and no invoice is issued. Credit is stored per customer and shown bycredit_balance. Rules 1-3 of Part 1 still apply. - Account credit is spent automatically on every invoice issued afterwards (including upgrade and cycle 0 invoices): the amount spent is
min(balance, invoice subtotal)where the subtotal is the sum of the lines *including any coupon discount*. If it is more than 0 the invoice gets a last line"account credit"with a negative amount, and the balance goes down by the same amount. An invoice total is never negative. invoice_cycle(sub_id, cycle)returns the id of the invoice for cycle numbercycle(an integer
>= 0), creating it if it does not exist yet. The new invoice has one line "<plan> plan, cycle <n>" for the price of the subscription's current plan, plus the account credit line if any. Checks, in this order: subscription not found; a negative cycle is error: invalid arguments; cycle not started if day is before the first day of that cycle; if that cycle was already invoiced return the existing id, creating nothing, spending no credit and not repricing, even if the subscription is cancelled; subscription cancelled; previous cycle not invoiced unless cycle is exactly one more than the latest invoiced cycle.
- Cycle 0's invoice (created by
subscribe) counts:invoice_cycle(sub, 0)returns it. Upgrade invoices are not cycle invoices.
Example. Ann is on team (subscribed on day 0) and downgrades on day 10 to basic: 8000 * 20 / 30 = 5333.33, so she is credited 5333. On day 30, invoice_cycle for cycle 1 creates an invoice with lines [["basic plan, cycle 1", 1999], ["account credit", -1999]] and total 0, and her balance falls to 3334. Calling it again returns the same invoice id and changes nothing.
Notes
models.py,errors.py,catalog.pyandconfig.pyare known-good.- You may use the AI assistant freely. Later parts must not break earlier ones; the hidden tests re-check every part. Finishing all three parts is the goal.
Example 1
- Input:
- [["subscribe",0,"ann","basic"],["change_plan",10,"sub-1","team"],["invoices",10,"ann"],["invoice",10,"inv-2"]]
- Output:
- ["sub-1",{"charged":5333,"credited":0,"invoice":"inv-2"},["inv-1","inv-2"],{"id":"inv-2","customer":"ann","day":10,"lines":[["upgrade basic to team, 20 days",5333]],"total":5333}]
- Explanation
- Ann upgrades on day 10 of a 30-day cycle, so 20 days remain: (9999 - 1999) * 20 / 30 = 5333.33, rounded once to 5333.
Example 2
- Input:
- [["create_coupon",0,"SAVE10","percent",10,2,null],["subscribe",1,"ann","basic","SAVE10"],["invoice",1,"inv-1"]]
- Output:
- [true,"sub-1",{"id":"inv-1","customer":"ann","day":1,"lines":[["basic plan, cycle 0",1999],["coupon SAVE10",-200]],"total":1799}]
- Explanation
- 10% of 1999 is 199.9, rounded to a 200 discount that appears as a negative line on the first invoice.
Example 3
- Input:
- [["subscribe",0,"ann","team"],["change_plan",10,"sub-1","basic"],["invoice_cycle",30,"sub-1",1],["invoice_cycle",31,"sub-1",1],["credit_balance",31,"ann"]]
- Output:
- ["sub-1",{"charged":0,"credited":5333,"invoice":null},"inv-2","inv-2",3334]
- Explanation
- The downgrade credits 5333. The cycle 1 invoice uses 1999 of it, and the retry returns the same invoice without spending more credit.
Guidelines
- Python 3 standard library only
- Amounts are integer cents; there is no floating-point money anywhere
- Days are integer day numbers and never decrease from one operation to the next; a billing cycle is 30 days
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Rounding is always to the nearest cent, once, on the total, with halves rounding up