Promotions and Amounts: A Miscalculated Discount Is Harder to Find Than an Oversell

· tech

#war-story#live-commerce#pricing

📑 Contents

An oversell explodes and a payment failure shouts; a miscalculated discount makes no sound at all. A customer who overpaid by 13 dollars won’t know, and neither will you if you undercharged by 13, until the day you’re digging through reconciliation one row at a time. This chapter is about promotions: how the rules are filed, how a clever algorithm lost to a single sentence from a host, and the dumb arithmetic that makes allocations always add up.

The map of promotions: rules, conventions, experiments

Promotions lived in three habitats:

With a coupon: one coupon = a combination of three parameters Effect amount off over a threshold free shipping over a threshold Threshold how much spend triggers it + product allowlist (what counts) Scope one round / several rounds all rounds combined e.g. "300 off over 3000 · round A only · selected products" No coupon: multi-buy offers buy N, get M free one product can carry several the ordering story is the next section Experiment layer: Django admin every flavour of buy-A-get-B applied urgently, experimentally promoted to a real rule once it holds
Parameterise what you'll repeat (the coupon's three axes), isolate what you're unsure about (the exotica in admin).
  • Coupon-based promotions converge into three orthogonal parameters: effect (discount / free shipping) × threshold (how much spend, plus an allowlist of “which products count towards it”) × scope (one round / several rounds / all rounds combined). A new coupon is a set of parameters, not a block of ifs — and the round becomes the system’s natural boundary for the fourth time: a coupon’s validity is expressed directly in rounds.
  • Multi-buy offers that need no coupon (buy some, get some free) hang off the product, and one product can carry several — the order they apply in is the star of the next section.
  • Anything that resists classification goes into the admin lab: every flavour of buy-A-get-B, applied urgently and experimentally. The last chapter’s maturity spectrum finds its most frequent use case here: marketing’s ideas will always outrun the rules table, and the lab lets an idea run first, earning a proper parameter only once it holds.

The optimal solution lost to ordering

Multi-buy offers stack, and a product carries several — so which combination should a customer’s cart get? The first answer was very engineer: write an optimal-combination algorithm that computes the cheapest possible application for them. It’s combinatorial optimisation, growing towards NP-hard, but the product counts were small enough to compute.

Then the host said: don’t. Just go in order and take the largest deduction each time.

It took me a long time to understand how right that request was. The problem with an optimal solution isn’t the compute, it’s that its answer can’t be explained to a person:

  • It’s unstable: add one more product and the whole optimal combination may rearrange — the discount figure on the screen jumps around, and the customer doesn’t think you’re clever, they think they’re being played.
  • It can’t be said out loud: the host has to explain the rule in one sentence on camera. “In order, take whichever discounts most” can be said; “our algorithm computes a globally optimal solution for you” is a complaint waiting to happen.
  • It isn’t monotone: under an optimal solution, buying more sometimes makes an existing discount disappear — “I bought one more, why did that get more expensive?” is a disaster support can’t finish explaining.

What the host wanted, stated precisely: promotions have a fixed order, and you apply each one until it can’t apply again before moving to the next — exhaust buy-5-get-3 before buy-3-get-2 gets a turn. The answer is stable, monotone and predictable — not globally cheapest, but understandable at every step. This is the same family of story as the state machine getting ripped out: the engineers wrote the clever one, the floor asked for the dumb one, and the floor was right. One line: an algorithm’s standard of correctness is defined by its context of use — in a live stream, “correct” means the customer follows it and the number doesn’t jump.

A digression worth taking: was it actually NP-hard?

Before we cut it, the algorithm deserves an honest complexity assessment — the answer has three layers, and the deepest one has nothing to do with complexity.

Layer one: one product at one price — this is unbounded knapsack, not NP-hard. Write the problem out: the customer orders nn paid units; each application of “buy aia_i, get bib_i free” consumes aia_i paid units and gives bib_i extra free; each paid unit can only be counted by one promotion. Maximise units given away, bixi\sum b_i x_i, subject to aixin\sum a_i x_i \le n. That’s unbounded knapsack (a cousin of the coin-change problem): formally weakly NP-hard, but with an O(n×k)O(n \times k) DP — where nn is the number of units bought, in reality a few dozen, solved instantly. What’s interesting is that greedy is already not optimal in this simplest case. Take buy-3-get-2 and buy-5-get-3, with greedy applying the bigger one first as the host ordered:

  • n=8n=8: buy-5-get-3 (consuming 5) + the remaining 3 on buy-3-get-2 — 5 free; the optimum is also 5. A tie.
  • n=9n=9: greedy applies buy-5-get-3, then buy-3-get-2, leaving 1 unit idle — 5 free; the optimum is buy-3-get-2 three times — 6 free.

Greedy loses for the same reason it does at coin change: a generous-looking promotion doesn’t necessarily have a high give-rate per paid unit — buy-5-get-3 gives 3/5 = 60%, buy-3-get-2 gives 2/3 ≈ 66.7%, and the winner is often decided by the remainder (that one paid unit left hanging). So precisely speaking, our greedy was never “near-optimal”, it was simply “a simple rule” — which is exactly why it was chosen, and there’s no need to crown it with optimality it doesn’t have.

And how cheap is a DP for this? Let f(j)f(j) be the most units you can get free using jj paid units:

f(j)=max(f(j1), maxi:aij{f(jai)+bi}),f(0)=0f(j) = \max\Big(f(j-1),\ \max_{i:\,a_i \le j} \big\{ f(j-a_i) + b_i \big\}\Big),\qquad f(0)=0

The answer is f(n)f(n), in O(n×k)O(n \times k). Walk n=9 through it:

jj123456789
f(j)f(j)002234456

f(9)=6f(9)=6, which is exactly “buy-3-get-2, three times”. But there’s a more important observation hiding in that table: look at f(5)f(6)f(5) \to f(6) — the optimal combination switches from buy-5-get-3 to buy-3-get-2 twice. The customer adds one product and the whole set of promotions on screen rearranges. DP gives you the optimal value, but an optimal solution’s composition jumps around with n — that’s a property of optimality itself, independent of the algorithm. DP can’t solve the explainability problem: even if compute were free, cutting the optimal solution was still right.

Layer two: the same product at several prices — the definition of “optimal” starts drifting. A product has a live price, a storefront price, per-variant prices, and the cart item carries a “special price” column for special promotions — so inside one cart, the same product can have several prices at once. Apply a promotion and which unit is the free one? Which price does the deduction use? Back then there was no explicit definition of “which price is definitely cheapest” — and here the optimal algorithm’s weakest point isn’t compute, it’s the specification: with “optimal” itself undefined, the algorithm is optimising an objective function nobody signed. And undefinedness doesn’t merely make the answer drift, it makes the space explode: to an optimiser, every undefined point is a variable — m price interpretations × n products is mnm^n worlds, each of which needs its own grouping optimum solved and compared. One missing sentence of specification doubles the search space; greedy applying in order is essentially killing variables with definitions — the order is a constant and each step’s rule is a constant, so the space collapses to a straight line. What it saves isn’t CPU, it’s specification debt.

Layer three: combinations across products are where NP-hard really begins. Ordinary multi-buy offers don’t span products, and coupons and non-coupons don’t affect each other (two independent layers) — so the ordinary problem is entirely solvable. But the moment the admin layer’s exotic buy-A-get-B spans products, plus the mutual exclusion of “each unit can only be consumed once”, the problem becomes weighted set packing: choose non-overlapping combinations from all possible promotion applications (each a weighted set of products) to maximise total discount — strongly NP-hard, with no DP to save you. The old instinct that “this should be NP-hard” was right about exactly this layer.

There’s a memorable test for “can this still be a DP”: look at whether the new condition asks “how many” or “which ones”. A condition that only changes counts or amounts survives with one more DP dimension (a spend threshold is this kind); a condition that starts needing to know which units (give away the cheapest, each unit with its own special price, cross-product bindings) destroys the interchangeability of units and the state can’t be compressed — DP dies of heterogeneity, not of rule count. And the engineering death usually comes earlier: every new condition forces you to redesign the state and re-prove correctness, so maintenance cost carries out the execution long before theory hands down its sentence.

Across all three layers, greedy wins once each: at layer one it loses on optimality and wins on simplicity; at layer two it defines the semantics with an order — “in order, largest deduction each time” answers both “how to compute” and “what counts as correct”, so where price semantics are ambiguous the process is the specification; at layer three it stops exotic promotions from dragging the whole cart into a combinatorial explosion. So the reasons for cutting the optimal solution, in order of importance: can’t be explained > isn’t defined > can’t be computed — and compute is the least important of the three.

Where the money lives: a column at each layer

The results of the promotion calculation get columns directly on orders payment, order and order item — whichever layer fits. That isn’t arbitrary, it’s the correct use of the three layers: a money column lives with its semantics — a product’s own multi-buy offer on the item, a round-limited coupon on the order (its scope is the round), an all-rounds-combined one on the payment. Amounts freeze at checkout (the commitment point principle), and invoices, refunds and reconciliation all stand on the frozen value.

Integers, flooring, subtraction: the arithmetic of allocation

Every amount is computed in integers — the first commandment of code that handles money. The real test is allocation: a round coupon took 100 off, spread across two products; the customer returns one of them, so how much do you refund?

Case: 699 + 301 = 1000, round coupon takes 100 → pay 900 ① split pro rata, then floor item A: 699 − (699/1000 × 100) = 629.1 → floor → 629 the 0.1 remainder: we absorb it ② the last one: finish by subtracting item B: 900 − 629 = 271 no ratio, just top up to the total subtraction guarantees the sum Check: 629 + 271 = 900 ✓ discount 70 + 30 = 100 ✓ refund item A and you refund 629 — every cent has an owner, and the books always balance Every similar allocation is handled this way — one rule, used system-wide
floor-and-subtract: flooring gives the remainder an owner (us), subtraction makes the totals identical — ugly, but every cent adds up.

There are only two rules: floor the pro-rata discounted price (the fraction goes to the customer and the company absorbs it — disputes always tip in the customer’s favour); and don’t compute a ratio for the last share, subtract up to the total (the totals being identical is guaranteed structurally, not by tests). It’s a pragmatic simplification of largest-remainder allocation, and it came with a good convention: every similar situation is handled this way — one allocation algorithm system-wide, so whoever reconciles only has to understand it once.

What a rebuild would do

Most of this survives: the three-axis parameter table, the admin experiment layer, applying in a fixed order until exhausted, and floor-and-subtract are all right. The rebuild list is short:

  1. A price isn’t a function, it’s a decision made live. The intuitive fix is to nail down a price-resolution order (special price > source price > variant price) and kill the mnm^n search space with rules — but that’s wrong: the price priority is the host’s judgment call, which the system can’t govern and shouldn’t. Live pricing is an art of the moment: she negotiated the goods, she calls the price, and whether this customer gets a special price is her commercial judgment. The right rebuild isn’t eliminating that freedom, it’s giving it a clean landing spot — the special-price column is the container for “a decision made live”, and the system’s job is recording the decision as a fact (who, when, what price), not deriving prices on the floor’s behalf. The variables in that search space aren’t killed by rules, they’re killed by the moment of pricing — a person decides, and the variable collapses into a constant. Mechanism to the system, policy to people, once again.
  2. Give the application order an explicit handle. Promotions carry an explicit order column set by the host or operations, and the algorithm simplifies to the extreme — in order, exhaust each one — not even needing “take the biggest each step”, because the order itself is the entire rule: sayable out loud, predictable, and changed by reordering rather than by a release.
  3. Promote the allocation rule from a convention to a class. floor-and-subtract was a “handle similar cases this way” convention; a rebuild converges it into a single allocation policy class (in Clean Architecture’s terms it’s a domain rule and deserves a name and a home), with a property test — for any input, the allocations sum exactly to the whole. A convention drifts as people come and go; a class with a test doesn’t.
  4. Dry-run before issuing a coupon. The promotion engine has the shape of a pure function (cart + rules → result), so it’s naturally replayable offline: before a new coupon goes live, run it over historical orders and see roughly what it will cost — the same prevention as the lesson from the stock chapter‘s migration incident: look at the numbers before shipping a rule that moves money.
  5. Don’t formalise the exotic buy-A-get-B. The more expressive a rules table gets, the closer the rules engine comes to being a programming language; keeping the two layers — a parameter table for what’s proven, admin isolation for what’s experimental — is enough.

Reflections

The graveyard of clever algorithms is explanation cost

The optimal-combination algorithm was among the most technically impressive code we wrote, and among the fastest to be cut. Why it lost to greedy is worth memorising: an algorithm’s total cost = compute cost + explanation cost, and in a consumer-facing system the second one is almost always the larger. When a customer asks “why is this the discount?”, support has to answer, the host has to say it out loud, and an engineer has to trace it — and an optimal solution fails all three. That’s the third time a host taught us design (the state machine, the re-call, greedy), and all three lessons are one: the core of floor wisdom is explainability, and the system either accommodates it or gets routed around.

Parameterise what you’ll repeat, isolate what you’re unsure of

What a promotions system fears most is growing into a sea of ifs — one special case per campaign, and two years later nobody dares touch it. The structure we had avoided that: repeating patterns (threshold × scope × effect) converged into a parameter table, so a new coupon is filling in a form; uncertain ideas (exotic buy-A-get-B) isolated into the admin lab, thrown away if they flop and promoted only once they stand. The point of a rules engine isn’t “able to express anything” — it’s making the repetitive cheap and the experimental safe. Two layers each doing their job is how marketing’s speed and the system’s maintainability stay alive at the same time.

Correctness with money is an accounting property, not a mathematical one

floor-and-subtract is ugly to a mathematician: the split isn’t exactly proportional and the last share is patched in by subtraction. But code that handles money was never answering “is the split accurate?” — it answers “do the totals match, does the remainder have an owner, and can it be traced afterwards?” That’s an accounting standard, not a mathematical one. Who gets the 0.1 doesn’t matter at all; what matters is that every cent has an owner, a refund always produces one definite number, and reconciliation always balances. In code that handles money, elegance yields to reconciliation — a line worth taping to every e-commerce engineer’s monitor.