Low-Level Design: Coupon and Promotion System — Validation, Redemption, Bulk Generation

Requirements

Functional: create coupons (percent off, fixed amount, free shipping), set constraints (minimum order, expiry, usage limits, specific products/categories, single use per user), apply coupon at checkout and compute discount, track redemptions, support bulk coupon generation (unique codes per user for campaigns).

Non-functional: coupon validation is idempotent (same coupon applied twice to the same order → same result), redemption is atomic (prevent same coupon from exceeding max_redemptions under concurrent checkout), codes are hard to guess (cryptographically random).

Core Entities

from enum import Enum
from dataclasses import dataclass, field
from typing import Optional, List, Set
from datetime import datetime

class DiscountType(Enum):
    PERCENT        = "PERCENT"        # 20% off
    FIXED_AMOUNT   = "FIXED_AMOUNT"   # $10 off
    FREE_SHIPPING  = "FREE_SHIPPING"

class CouponStatus(Enum):
    ACTIVE   = "ACTIVE"
    PAUSED   = "PAUSED"
    EXPIRED  = "EXPIRED"
    DEPLETED = "DEPLETED"

@dataclass
class Coupon:
    coupon_id: str
    code: str                          # human-readable code: "SUMMER20"
    discount_type: DiscountType
    discount_value: int                # 20 (%) or 1000 (cents for $10 off)
    status: CouponStatus

    # Constraints
    minimum_order_cents: int = 0       # min cart value to apply
    max_redemptions: Optional[int] = None  # None = unlimited
    max_per_user: int = 1              # usually 1 (single use per user)
    expires_at: Optional[datetime] = None
    valid_from: Optional[datetime] = None

    # Targeting
    applicable_product_ids: Set[str] = field(default_factory=set)   # empty = all
    applicable_category_ids: Set[str] = field(default_factory=set)  # empty = all
    applicable_user_ids: Set[str] = field(default_factory=set)      # empty = all users

    # Tracking
    redemption_count: int = 0

@dataclass
class Redemption:
    redemption_id: str
    coupon_id: str
    user_id: str
    order_id: str
    discount_applied_cents: int
    redeemed_at: datetime

Coupon Validation

class CouponValidator:
    def validate(self, coupon: Coupon, user_id: str, cart) -> tuple:
        """Returns (is_valid, error_message)"""
        now = datetime.utcnow()

        if coupon.status != CouponStatus.ACTIVE:
            return False, f"Coupon is {coupon.status.value}"

        if coupon.expires_at and now > coupon.expires_at:
            return False, "Coupon has expired"

        if coupon.valid_from and now = coupon.max_redemptions:
            return False, "Coupon has reached its usage limit"

        # Per-user limit
        user_redemption_count = db.count_redemptions(coupon.coupon_id, user_id)
        if user_redemption_count >= coupon.max_per_user:
            return False, "You have already used this coupon"

        # User targeting
        if coupon.applicable_user_ids and user_id not in coupon.applicable_user_ids:
            return False, "This coupon is not valid for your account"

        # Minimum order
        applicable_amount = self._get_applicable_amount(coupon, cart)
        if applicable_amount  int:
        """Sum of cart items that qualify for the coupon."""
        if not coupon.applicable_product_ids and not coupon.applicable_category_ids:
            return cart.subtotal_cents   # applies to everything
        applicable = sum(
            item.price_cents * item.quantity
            for item in cart.items
            if (item.product_id in coupon.applicable_product_ids or
                item.category_id in coupon.applicable_category_ids)
        )
        return applicable

Discount Calculation

class DiscountCalculator:
    def calculate(self, coupon: Coupon, cart) -> int:
        """Returns discount amount in cents."""
        applicable_amount = CouponValidator()._get_applicable_amount(coupon, cart)

        if coupon.discount_type == DiscountType.PERCENT:
            discount = int(applicable_amount * coupon.discount_value / 100)
        elif coupon.discount_type == DiscountType.FIXED_AMOUNT:
            discount = min(coupon.discount_value, applicable_amount)  # can't exceed order total
        elif coupon.discount_type == DiscountType.FREE_SHIPPING:
            discount = cart.shipping_cents

        return discount

    def apply_to_order(self, coupon_code: str, user_id: str, order_id: str, cart) -> dict:
        coupon = db.get_coupon_by_code(coupon_code)
        if not coupon:
            raise ValueError("Invalid coupon code")

        is_valid, error = CouponValidator().validate(coupon, user_id, cart)
        if not is_valid:
            raise ValueError(error)

        discount_cents = self.calculate(coupon, cart)

        # Atomic redemption: increment count with max check
        success = db.execute(
            "UPDATE coupons SET redemption_count = redemption_count + 1 "
            "WHERE coupon_id = %s AND (max_redemptions IS NULL OR redemption_count < max_redemptions)",
            [coupon.coupon_id]
        )
        if success == 0:
            raise ValueError("Coupon has just reached its usage limit")

        redemption = Redemption(
            redemption_id=generate_id(),
            coupon_id=coupon.coupon_id,
            user_id=user_id,
            order_id=order_id,
            discount_applied_cents=discount_cents,
            redeemed_at=datetime.utcnow(),
        )
        db.save(redemption)
        return {'discount_cents': discount_cents, 'redemption_id': redemption.redemption_id}

Bulk Code Generation

import secrets
import string

def generate_coupon_code(length: int = 8) -> str:
    """Generates a cryptographically random, URL-safe coupon code."""
    alphabet = string.ascii_uppercase + string.digits
    # Remove ambiguous characters: 0, O, I, 1
    alphabet = alphabet.replace('0', '').replace('O', '').replace('I', '').replace('1', '')
    return ''.join(secrets.choice(alphabet) for _ in range(length))

def bulk_generate(base_coupon: Coupon, user_ids: List[str]) -> List[Coupon]:
    """Generate unique coupon codes for a targeted campaign."""
    coupons = []
    for user_id in user_ids:
        code = generate_coupon_code()
        while db.coupon_code_exists(code):  # ensure uniqueness
            code = generate_coupon_code()
        coupon = Coupon(
            coupon_id=generate_id(),
            code=code,
            discount_type=base_coupon.discount_type,
            discount_value=base_coupon.discount_value,
            status=CouponStatus.ACTIVE,
            max_per_user=1,
            applicable_user_ids={user_id},   # single-user coupon
            expires_at=base_coupon.expires_at,
        )
        coupons.append(coupon)
    db.bulk_insert(coupons)
    return coupons

Key Design Decisions

  • Atomic redemption: the UPDATE with redemption_count < max_redemptions in the WHERE clause prevents over-redemption under concurrent checkouts. If the UPDATE returns 0 rows, the coupon is depleted.
  • Idempotency: store order_id in the redemption record. If the same order is submitted twice (client retry), check if a redemption already exists for (coupon_id, order_id) and return the existing result.
  • Rollback on order failure: if payment fails after coupon is applied, decrement redemption_count and delete the redemption record. Wrap coupon redemption and payment in a saga pattern: apply coupon → charge payment → on payment failure, compensate by reversing coupon.
  • Stacking coupons: most platforms allow only one coupon per order. If stacking is supported, validate each coupon independently and sum discounts, capping at the order total.

Interview Questions

Q: How do you prevent coupon abuse (same user applying a “single-use” coupon twice)?

Enforce uniqueness at the database level: a unique constraint on (coupon_id, user_id) in the redemptions table — only one row per user per coupon. The INSERT fails with a unique constraint violation if the user has already redeemed. At the application level, query the redemption count before allowing checkout to show the user an early error. For high-traffic coupons, cache the user’s redemption status in Redis (TTL = coupon expiry) to avoid a DB query on every cart page load.

Q: How would you design a flash-sale coupon that only 100 users can use?

Set max_redemptions=100. Under concurrent checkout, use the atomic UPDATE pattern: UPDATE coupons SET redemption_count = redemption_count + 1 WHERE code='FLASH100' AND redemption_count < 100. If 0 rows updated, the coupon is sold out. For very high concurrency (10K users hitting “apply” simultaneously), use a Redis counter with INCR + check: if INCR coupon:FLASH100:count > 100: DECR coupon:FLASH100:count; return "sold out". Atomic at the Redis level, no database lock contention.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How do you prevent a coupon from being used more than its maximum redemption limit?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Use an atomic database update: UPDATE coupons SET redemption_count = redemption_count + 1 WHERE coupon_id = X AND (max_redemptions IS NULL OR redemption_count < max_redemptions). If 0 rows are updated, the coupon is depleted. This prevents race conditions where two users simultaneously pass the pre-check (redemption_count max_redemptions, DECR and return “sold out” u2014 no database write needed. Only the 10K successful incrementers proceed to the database path. Cache the full coupon object in Redis (HGET) to avoid a DB read on every validation. Use read replicas for validation queries. The database atomic UPDATE (with WHERE redemption_count < max) serves as the final safety net for any Redis failures. Pre-warm the Redis cache before the sale goes live."
}
}
]
}

Asked at: Shopify Interview Guide

Asked at: Stripe Interview Guide

Asked at: Airbnb Interview Guide

Asked at: DoorDash Interview Guide

Scroll to Top