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

Source: https://www.techinterview.org/post/3233464581/lld-coupon-promotion-system/
Updated: 2026-07-05 · techinterview.org

## 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.valid_from:
            return False, "Coupon is not yet active"

        # Global redemption limit
        if coupon.max_redemptions is not None and coupon.redemption_count >= 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 < coupon.min_order_cents:
            return False, f"Minimum order of ${coupon.min_order_cents / 100:.2f} required"

        return True, None

    def _get_applicable_amount(self, coupon: Coupon, cart) -> 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.

Asked at: [Shopify Interview Guide](https://techinterview.org/shopify-interview/)

Asked at: [Stripe Interview Guide](https://techinterview.org/stripe-interview/)

Asked at: [Airbnb Interview Guide](https://techinterview.org/airbnb-interview/)

Asked at: [DoorDash Interview Guide](https://techinterview.org/doordash-interview/)
