Coupon logic that survives contact with the internet
A D2C skincare brand in Noida ran a 20% off code for influencers, a flat Rs 100 off code for first-time buyers, and a free shipping coupon for cart-abandonment recovery. Within four hours of launch, someone on a Telegram deal group figured out that all three stacked on a Rs 499 order, bringing the total to Rs 199 with free shipping. The brand lost Rs 38,000 before someone noticed.
The problem is not that coupon stacking is bad. It is that the rules were never written down as rules. They were written as flags on each coupon — `stackable: true` — with no global policy enforcing what that actually means.
We build coupon logic as a pipeline of decisions, not a set of per-coupon toggles. Every coupon carries a `priority` and a `conflict_group`. The cart evaluation applies them in order, and the first coupon in a conflict group wins. If two coupons are in different groups, they stack. If they are in the same group, the higher priority one applies and the other is silently dropped. The merchant sees both in the admin panel, with the dropped one marked as "overridden by WELCOME100."
This is more code than a simple boolean, but the boolean is a lie. "Stackable" on its own means nothing until you answer: stackable with what?
Shared codes are the other problem that surfaces within hours. A code meant for 500 influencers will reach 50,000 people. We treat every coupon as potentially public from day one. The controls that matter are usage limits per customer, not per code. We hash the customer identifier — phone number or email — and store it in a `coupon_redemptions` table with a unique constraint on `(coupon_id, customer_hash)`. Postgres rejects the second attempt. No application-level check, no race condition, no apology email.
For first-time buyer codes, we check against order history, not account creation. A customer who made an account in 2021 and never ordered is still a first-time buyer. A customer who checked out as a guest last week is not. This means the check runs against the orders table, not the users table, and it runs inside the same transaction as the coupon redemption.
The code looks roughly like this:
def evaluate_coupons(cart, customer_id):
coupons = cart.applied_coupons.order_by('priority')
applied = []
seen_groups = set()
for coupon in coupons:
if coupon.conflict_group in seen_groups:
continue
if not meets_conditions(coupon, cart, customer_id):
continue
applied.append(coupon)
if coupon.conflict_group:
seen_groups.add(coupon.conflict_group)
return apply_discounts(cart, applied)The `meets_conditions` function is where the business rules live: minimum cart value, category restrictions, customer eligibility, usage count. Each condition is a separate function that returns a boolean and a reason string. When a coupon fails, we return the reason to the frontend so the customer sees "Minimum cart value is Rs 599" instead of "Invalid coupon."
One tradeoff: we do not support percentage-off coupons with a maximum discount cap without making the cap explicit in the admin UI. Merchants enter "20% off" and forget the cap field. A Rs 10,000 order with 20% off is Rs 2,000, which is often more than the margin. We make the cap field required for percentage coupons and default it to the product's cost price times quantity. Merchants complain about the extra field. We keep it anyway because the alternative is a support call at 11 PM asking why they lost money on a bulk order.
We also do not support buy-one-get-one as a coupon. BOGO is a pricing rule, not a discount. It belongs in the product catalog, not the coupon engine. We have seen vendors try to implement BOGO through coupons, and it always ends with a cart that has three items, a coupon that expects two, and a discount calculation that produces a negative number. We tell merchants we will build it as a separate promotion module if they need it. They usually do not need it.
The coupon engine we ship is about 400 lines of Python plus the admin UI for creating and managing coupons. It is not clever. It is a pipeline with a conflict group check, a conditions evaluator, and a redemption log with a database constraint. The reason it works is that every decision is explicit and every rule is visible in the admin panel. The merchant can see why a coupon was rejected. The developer can trace the evaluation order. Nobody has to guess what "stackable" means.