DEV Community

Cover image for Designing a Discount-Validation API: Catching Stacked-Coupon Math Errors Before They Hit Production
Tea-sip for Lizely

Posted on

Designing a Discount-Validation API: Catching Stacked-Coupon Math Errors Before They Hit Production

If you've ever wired up a promo engine, you know the cheap-looking line items are where the bugs hide. A single product with three sequential percentage discounts — "20% off, then 10% off, then a 15% loyalty coupon" — is mathematically distinct from a flat 45% off, and most homegrown calculators get it wrong in the same three or four ways. This article walks through the engineering side of that problem: how to validate discount math in code, what edge cases to cover, and what the spec actually says about rounding so you can stop arguing with finance.

The goal isn't to sell you anything. It's to give you a checklist and a set of unit tests you can drop into a promo or pricing module today.

Why Stacked Percentage Discounts Aren't Additive

The naive implementation is always the same: price * (1 - (d1 + d2 + d3)). It's wrong because percentages compose multiplicatively. Two discounts of 20% and 10% applied in sequence give 0.8 × 0.9 = 0.72, which is a 28% total — not 30%. The arithmetic is described cleanly in any precalculus text, and the relevant property is the multiplicative inverse: each successive discount multiplies the remaining price by a factor, not the original.

This matters because the user-visible copy on a coupon almost always reads "20% + 10% off!" even when the underlying math is sequential. If your API returns a number that disagrees with what the customer saw in their cart, you've created a chargeback. The safer pattern is to model discounts as a list of (factor, label) tuples and let the calculation walk through them in order, surfacing each step in the response so the front end can show "20% off, then 10% off = 28% total" if it wants to.

A second trap is order-dependence. 0.8 × 0.9 and 0.9 × 0.8 happen to be commutative here, but the moment you add BOGO or a fixed-amount coupon to the mix, order stops being free. Decide the order at the data layer (typically: best-percent-first, fixed-amount last) and document it in the API spec so the front-end team doesn't rearrange coupons client-side.

The Core Calculation, Spelled Out

Strip the problem to its bones and you're computing:

final = price × Π (1 - d_i) − Σ fixed_j
clamp(final, 0, price)
Enter fullscreen mode Exit fullscreen mode

Where d_i are percentage discounts in [0, 1] and fixed_j are absolute-amount coupons in the same currency. The clamp is non-negotiable: a stack of "free shipping" and "$50 off a $30 item" should never go negative. Naïve subtraction without clamping is one of the classics you see in bug reports — a customer gets a $20 credit on a $12 order and the system tries to refund money that doesn't exist.

The "compare-stacked-coupons-accurately" walkthrough on Lizely covers the spreadsheet version of this same calculation, including the trick of comparing two retailers by computing their effective multiplier side-by-side rather than by adding percentages. If you're building the calculation once in code rather than re-deriving it per spreadsheet, the principles are identical: calculate discount in Excel and compare stacked coupons accurately is the kind of reference you want pinned next to your pricing.md design doc so finance and engineering are looking at the same numbers.

Rounding is the third leg of the stool. Most jurisdictions require that the final charged amount round half-away-from-zero to the nearest cent, but you can round intermediate steps either way as long as you're consistent — and the consistency has to be documented, because it's the kind of thing auditors will ask about. The IEEE 754 standard for floating-point arithmetic is why you should never store currency as a JS Number: use integer cents (or a Decimal type) and only convert to a display string at the boundary.

A Unit-Test Checklist for the Promo Module

Here's the minimum set of cases I'd expect to see in promo.test.ts (or the language equivalent) before shipping a discount calculator to production. It maps cleanly to Jest/Vitest/xUnit and is the same shape an integration test would use.

  1. Single percentage100 × 0.9 = 90.00. Confirms the trivial path.
  2. Two stacked percentages100 × 0.8 × 0.9 = 72.00. Confirms you aren't adding them to 170.
  3. Three stacked percentages100 × 0.8 × 0.9 × 0.85 = 61.20. Catches off-by-one in accumulation order.
  4. Percentage + fixed amount100 × 0.8 − 10 = 70.00. Catches a fixed-coupon applied before percentages.
  5. Negative-result clamp20 × 0.5 − 50 = clamp(0). Confirms no negative line items.
  6. Zero-percent edge50 × 1.0 = 50.00. Catches a null discount treated as NaN.
  7. Full-discount edge50 × 0 = 0.00. Confirms the API returns 0, not null.
  8. Currency rounding — inputs that produce a third-decimal-place value (e.g. 33.33 × 0.333) must round to two places deterministically across runs.
  9. Mutually exclusive coupons — applying both SUMMER25 and WELCOME10 when the spec forbids it must throw, not silently pick one.
  10. Empty discount listprice returned unchanged, with a discount-count of zero so the front end can render "no promotions applied".

If any of those fails, the bug is almost always one of three things: additive instead of multiplicative math, premature rounding, or a missing clamp. They're easy to find with a bisecting commit once you have the test in place.

Debugging Real-World Mismatches

When finance and engineering disagree on a number, the disagreement is almost always one of five things. Walk through them in order before touching code:

  • Rounding step. Someone rounded each percentage to two decimals before multiplying, which is wrong on a 33.333…% promotion. Multiply first, round once.
  • Tax inclusion. A "$100, 20% off, plus 8% tax" problem is three different numbers depending on whether the 20% applies before or after tax. Confirm the cart's tax model.
  • Stacking order. Two discounts applied in different orders produce different totals when one is percentage and one is fixed. Check the order spec.
  • Currency mismatch. A "$10 off" coupon stored in USD applied to a EUR cart. Convert at the order's locked-in rate, never at request time.
  • Stale coupon config. The cache served a coupon that was valid yesterday but expired this morning. Hit the coupon service directly to confirm.

A useful trick is to log the full discount trace alongside the order in your observability stack. A discounts_applied array of {label, factor, fixed_amount} with the running subtotal at each step is the cheapest way to make a five-minute disagreement into a thirty-second one. Trace it back through the HTTP request lifecycle if the request came from the browser; the cookie or local storage often has a cached cart that disagrees with the server.

Production Constraints Worth Naming Out Loud

A few constraints will show up the first week you're in production, and they're worth deciding before a customer finds them for you.

Idempotency. Recomputing the same order with the same coupons must return the same total, byte for byte. That sounds obvious until a coupon service flaps and the customer hits "refresh" on the checkout page.

Auditability. Every line of the final price must be reconstructable from inputs six months later for a chargeback dispute. Store the discount trace with the order, not just the final number.

Performance. A cart with 200 line items and 5 promotions each is 1000 multiplications — trivial. A cart with 10,000 items and an apply_promo_to_category walk is more interesting; cache category lookups per cart, not per request.

Backward compatibility. Once finance signs off on a rounding rule, treat it as part of the contract. Changing it mid-quarter requires a migration story for historical orders.

Frequently asked questions

Should I round after each discount or only at the end?

Round only at the end. Rounding intermediate steps introduces up to half-a-cent of error per discount, and three stacked discounts can drift the final number by a cent or more. The exception is when you need a display value mid-flow — the user sees "after coupon 1: $72.33" — and even then, round from the canonical integer-cents representation, not from a re-rounded float.

What's the right way to handle a coupon that no longer exists?

Return an explicit error, not a silent zero. The API contract should distinguish coupon_not_found, coupon_expired, and coupon_not_eligible_for_cart, because the front end needs to render them differently. Silently dropping an expired coupon is the kind of bug that surfaces as a "my coupon disappeared!" ticket.

How do I prevent two coupons from stacking when the spec forbids it?

Enforce exclusion at the coupon-acceptance step, not at the calculation step. When a customer tries to apply a second coupon, validate it against the already-applied set first and reject early. Calculating a stacked total and then throwing it away wastes a round-trip and confuses the front-end team.

Where does tax fit in this model?

Tax is downstream of the discount calculation. Compute the post-discount subtotal in integer cents, then apply tax on that subtotal. Applying tax before discounts is technically defensible in some jurisdictions but it's the minority case, and you should be explicit about which model you've picked — finance will ask.


This article was drafted with AI assistance and reviewed for technical accuracy before publishing.

Top comments (0)