Skip to content
QSWEQB
hardScenarioDesignMidSeniorStaff

Your promotions engine lets two discounts stack when it should not. How do you fix it and stop it recurring?

Model eligibility, exclusivity, and priority as explicit promotion data rather than accumulated conditionals, pin a deterministic evaluation order because the same two discounts applied in a different sequence produce a different total, and persist a price trace finance can read months later.

6 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate separate the immediate mitigation from the modelling change, rather than offering only one of the two
  • Whether exclusivity is expressed as data on the promotion instead of another branch in the pricing code
  • That they recognise the order of application changes the final total, and can demonstrate it with numbers
  • Whether the evaluation order is made explicit and stable rather than inherited from collection or query order
  • Does the candidate produce an artefact that explains a historical price, not merely one that computes a current one

Answer

Stop the bleeding, then stop pretending it is a bug

The first hour is operational. Find the promotion pair, disable one of them, and measure the exposure: how many orders were priced with both, what the discount delta was, and whether any have already shipped or been refunded. That number decides whether this is a footnote or a recovery exercise, and it is the first thing anyone senior will ask for.

The second hour is where the interview is really being conducted. The tempting fix is to find the branch that let both through and add a condition to it, which will work and will also guarantee a recurrence, because the reason two promotions stacked is not that one condition was wrong. It is that nowhere in the system does a statement exist about which promotions may combine. That knowledge lived in a marketing manager's head, was translated into a conditional by whoever built the second campaign, and was never written down in a form the engine could read. The next campaign will be built by someone else, and they will not know about your new condition.

So the durable fix is to move exclusivity out of code and into the promotion definition, where the people who own the policy can state it and the engine can enforce it uniformly.

Exclusivity as data, not as accumulated conditionals

Every promotion needs a small number of fields that together make combination decidable without reading the pricing code. Eligibility - who, when, which channel, which cart conditions - answers whether the promotion is a candidate at all. Target scope says what it discounts: a line, a shipment, or the order total. The benefit says how much. And then the fields that this incident is about.

Exclusivity works best as group membership rather than as pairwise blacklists. Give each promotion a set of exclusivity groups, with the rule that at most one promotion from any group can be applied to the same cart. A pairwise "cannot combine with promotion X" list looks simpler with two promotions and is unmaintainable with two hundred, because every new campaign requires editing every existing one. Alongside that, a single global exclusivity flag is genuinely useful for the "this offer cannot be combined with any other" case that legal and marketing both understand.

{
  "id": "SUMMER20",
  "version": 3,
  "benefit": { "type": "percent_off", "value": 20, "scope": "order_subtotal" },
  "eligibility": { "channels": ["web"], "minSubtotalMinor": 5000 },
  "exclusivityGroups": ["seasonal-sitewide"],
  "exclusiveOfAllOthers": false,
  "priority": 100,
  "maxDiscountMinor": 5000
}

Two properties of that shape matter more than the field names. The definition is versioned rather than edited, so an order placed last Tuesday can still be repriced identically today. And the cap is a field rather than a hope, because an uncapped percentage on an unexpectedly large cart is the second-most-common way a promotions engine loses money.

Why patching the condition is the wrong fix

Here is the thing that separates a strong answer from an adequate one, and it survives even after exclusivity is modelled correctly: the set of applicable promotions does not determine the total. The sequence does.

Take a £100 cart, a 20% discount, and a £10 discount, and assume for the moment they are legitimately allowed to stack. Apply the percentage first and you get £80, then £70. Apply the fixed amount first and you get £90, then £72. Same cart, same two promotions, same intent - a £2 difference, and multiplied across a campaign that is a real number on a real P&L. Nothing about the exclusivity model prevents this, and a fix that only stops illegal stacking leaves you with legal stacking that produces different answers depending on how a list happened to be sorted.

A related decision hides in the same place: whether a percentage applies to the original list price or to the running subtotal after earlier discounts. Both are defensible, they diverge as soon as two percentages combine, and if it is not stated somewhere it is being decided implicitly by evaluation order.

So the fix has two halves. Exclusivity says which promotions apply. An explicit, stable evaluation order says what they compute to.

Determinism has to be specified, not assumed

Determinism means that the same cart, the same promotion set at the same versions, and the same effective timestamp always produce the same total, on any node, after any deployment. That is not free, and the ways it leaks are dull and consistent.

Order the candidates by an explicit priority field, and break ties on the immutable promotion identifier so the sort is total rather than merely partial. Never inherit the order from a query without an ORDER BY, from a hash-based collection, or from the sequence in which campaigns were authored. Settle the reference-amount question - list price or running subtotal - once in the engine rather than per promotion. And pass the effective timestamp in as a parameter instead of calling the clock inside the evaluator, both because it makes the function testable and because a cart evaluated at 23:59:59 must not change price between the quote and the confirmation.

When several mutually exclusive candidates all qualify, someone has to choose, and "best for the customer" is a search rather than a rule: with enough interacting promotions, evaluating every valid combination is combinatorially expensive. Bound it deliberately - a documented greedy rule, or exhaustive search over a capped candidate set - and record which rule was used. An engine that picks whichever discount it evaluated last has made a pricing decision nobody authorised.

Carts, not unit tests of predicates

Unit tests over individual eligibility predicates will pass while the engine still misprices, because the defect is in the interaction. The suite that actually protects you is table-driven over realistic carts: cart contents, active promotion set with versions, customer segment, timestamp, and the expected per-line and per-order outcome. Build it from carts that exist - the top patterns from production traffic, the awkward ones from support tickets, and the exact cart from this incident, which becomes a permanent regression case named after the ticket.

Layer property-based checks over that, because they catch what nobody thought to enumerate. A total may never go negative, a discount may never exceed its stated cap, no line may fall below a contractual floor, and applying a promotion twice must be indistinguishable from applying it once. Be careful with the properties that sound obvious and are false: adding an item can legitimately lower the total when it crosses a threshold, so that one has to be tested rather than asserted.

This suite is cheap to run because of the design decision above. Once evaluation is a pure function of cart, versioned promotion set, and timestamp, every case is a fixture and a comparison, with no database and no clock involved.

The record that lets finance explain a price

The last piece is the one that gets discovered too late. Six weeks after this incident, someone in finance is looking at a single order and needs to know why it was £70 rather than £80, and the promotions have since been edited or retired. If the only stored figures are the line prices and a total discount amount, that question cannot be answered, and the whole conversation becomes an argument between reconstructions.

So persist a price trace on the order at the moment of pricing, structurally rather than as a log line: for each applied promotion, its identifier and version, its position in the evaluation sequence, the base amount it applied to, the discount it produced, and which line or shipment absorbed it. Record the rejected candidates too, each with a reason code - outside its active window, excluded by group, cap reached, ineligible segment. Rejections are what tell you whether the engine behaved as designed or as coded.

That trace does three jobs at once. It gives finance a defensible answer without a rebuild, it gives support something to read to a customer who thinks their voucher did not work, and it gives you the diagnosis for the next incident of this class in minutes rather than by bisecting deployments - which is the real reason the recurrence stops.

Likely follow-ups

  • Two mutually exclusive promotions both qualify. How do you choose, and who decides whether you optimise for the customer or the margin?
  • An order-level discount has to be split across lines for tax and partial refunds. How do you allocate it?
  • A marketing manager edits a live promotion's percentage. What happens to orders already placed under it?
  • How would you let non-engineers author a promotion without letting them create this bug again?

Related questions

promotionspricingrules-enginedeterminismauditability