Ten objects and two decisions: how money is represented, and the order discounts apply in. Get either wrong and the total is off by real money or cannot be explained line by line.
The one requirement
- Output is a quote: subtotal, an itemised discount log (label + exact amount), tax, total.
- Invariant that drives everything: subtotal minus listed discounts equals total, exactly, no tolerance. A total you cannot explain line by line is broken even when the number is right.
- Stacking = applying more than one promotion to a cart. SKU = the store’s unique product code. Mill = one thousandth of a currency unit (a tenth of a cent), for sub-cent prices like fuel or weighed goods.
Money: integers, never float
- Money = whole number of the smallest unit (cents), carried with a currency tag so different currencies cannot be added by accident.
- Float has no exact binary form for
0.10or0.07, so the ledger stops summing. The error has no consistent direction, so no fudge factor fixes it.- Ten
0.10adds to0.9999999999999999;round(2.675, 2) == 2.67;int(1.15 * 100) == 114; a hundred0.07drifts UP to7.000000000000009.
- Ten
Decimalis the acceptable second answer: use only for genuinely sub-cent unit prices; slower, and still needs a rounding mode pinned down.- Rounding does not vanish, it becomes explicit. State one rule in one function (e.g. discounts round in the customer’s favour, tax half-up). 20% off $19.99 = 399.8c: floor gives $3.99 off, half-up gives $4.00. About half of discounted lines disagree by a cent.
Core objects (10)
| Object | Responsibility | Mutability |
|---|---|---|
Catalog | SKU → Product lookup, versioned | — |
Product | SKU, name, unit price, tax class | immutable (new record on price change) |
LineItem | product + quantity | mutable |
Cart | the lines, nothing else — no total() | mutable |
PricingEngine | lines + promotions → total + discount log | stateless |
Promotion | one rule, one stage (Strategy) | — |
Discount | label, amount, source | immutable |
Quote | subtotal, discount log, total; self-validating | frozen |
Inventory | stock levels, atomic reservation | mutable |
Order | frozen snapshot: lines, discounts, tax, payments | immutable |
Carthas no total: a total depends on promotions, membership, and the date, none of which the cart knows.- Cart vs Order is the common structural error: opposite mutability. Merge them and a printed receipt’s total can still change.
Decision 1: promotions are Strategies and do not commute
- Discounts do not commute: 3 x $10 with
PercentOff(20)+AmountOff(500)= $19.00 percentage-first vs $20.00 fixed-first. A percentage is worth whatever the running subtotal is when it fires; the fixed coupon is worth the same anywhere. - The bug is not the number, it’s that list order decided it and nobody chose deliberately.
- Fix: give every promotion a stage (a number for its pricing phase); the engine sorts by stage before applying. One line:
sorted(self.promotions, key=lambda p: p.stage.value). - Gaps not sequence (10, 20, 30, 40) so new stages insert without renumbering. Within a stage, pick a documented tiebreak (largest discount first).
- Cost: rules spread across one class per rule + a stage table, and a promotion sees only the
runningsubtotal, so “20% off items not already discounted” needs a richer context object.
Subtotal
-> LINE (10) per-item: buy-2-get-1, bundles, member unit price
-> ORDER_PERCENT (20) % off the running subtotal
-> ORDER_FIXED (30) fixed-amount coupons, applied last
-> LOYALTY (40) points redemption
-> Quote subtotal, discount log, total
Quote: two invariants in the constructor
- Reconciliation: subtotal − logged discounts == total, exactly.
- Plausibility:
0 <= total <= subtotal. Reconciliation alone passesPercentOff(200)(total −3000, hands the shopper $30) or a negative surcharge; the bounds check refuses both. - Enforced in
__post_init__of a frozen object with a tuple log, so a broken quote cannot be constructed and cannot be mutated afterward.
Decision 2: inventory oversell race
- Race condition: two registers read
stock == 1before either writes, both sell the last unit. Read-decide-write with a gap. - Fix: make check-and-decrement one indivisible step.
- In memory: a
threading.Lockaround read + write. - At store scale:
UPDATE stock SET qty = qty - 1 WHERE sku = ? AND qty >= 1, then check the affected row count (zero = refuse). The DB does it atomically.
- In memory: a
- A separate
SELECTto display a number is fine, never a correctness mechanism. - Reframe: a supermarket does not prevent oversell at the till (goods are already in the cart); the real invariant is inventory accuracy. Reservation-before-payment belongs to online order-and-collect.
Extensions, priced in edits
- Member pricing:
apply(lines, running)→apply(lines, running, ctx); changes every promotion class. Lesson: pass a frozen context object to a Strategy, never positional args. - Stacking: add an exclusivity group tag per promotion; two sharing a tag are mutually exclusive, best applies. Kept separate from stage (may they run together? vs which first?). Resolve within a single stage only (cross-stage is
2^n). - Returns: attribute order-level discounts down to lines so a returned apple refunds its post-discount price ($6.33, not the $10.00 sticker). Use largest remainder: floor each share, hand leftover cents to the biggest discarded fractions, deterministic tiebreak by index. Store the allocation at settle (prices/promos may change later). Raise if weights sum to zero.
Gotchas
- Do not let
Carthold a total. - Do not let list order decide pricing; declare a
Stage. - Do not make
Cataloga Singleton; construct once and inject. - Tax goes on the post-discount total, per
TaxClassfield onProduct(groceries often zero-rated), through the same integer path, never a buriedif. - Assumed fixed and hard-coded: one currency (
Cents = int), whole quantities, single-pass pricing, single-threaded pricing. Multiple currencies means aMoneyvalue object decided on day one.