Low-Level Design: Loyalty and Rewards Program — Points, Tiers, Redemption, and Expiry

Core Entities

Member: member_id, user_id, tier (BRONZE, SILVER, GOLD, PLATINUM), points_balance, lifetime_points_earned, tier_qualifying_points_ytd, tier_expires_at, enrolled_at. PointTransaction: txn_id, member_id, txn_type (EARN, REDEEM, EXPIRE, ADJUST, BONUS), points_delta (positive=earn, negative=redeem/expire), source_type (PURCHASE, REFERRAL, BONUS_CAMPAIGN, PARTNER), source_reference_id, expiry_date, created_at. RedemptionOption: option_id, name, points_required, type (DISCOUNT, FREE_ITEM, GIFT_CARD, PARTNER_REWARD), value_cents, is_active. Tier: tier_name, min_qualifying_points, earn_multiplier (1x, 1.5x, 2x, 3x), perks[].

Points Accrual

On qualifying purchase: base_points = floor(order_amount_cents / 100) (1 point per dollar). Apply tier multiplier: earned_points = base_points * tier.earn_multiplier. Insert PointTransaction with txn_type=EARN, expiry_date = NOW() + 1 year (or program-specific policy). Update member.points_balance += earned_points. Update member.lifetime_points_earned += earned_points. Update tier_qualifying_points_ytd for tier evaluation. The points ledger is the source of truth — never update a balance column without a corresponding transaction record. The balance is derivable by summing all transactions but is cached for performance.

class PointsService:
    def award_points(self, member_id: int, order_id: str, amount_cents: int) -> int:
        member = self.db.get_member(member_id)
        tier = self.tier_config[member.tier]
        base = amount_cents // 100
        earned = int(base * tier.earn_multiplier)
        expiry = date.today() + timedelta(days=365)
        txn = PointTransaction(
            member_id=member_id, txn_type='EARN', points_delta=earned,
            source_type='PURCHASE', source_reference_id=order_id, expiry_date=expiry
        )
        self.db.insert_transaction(txn)
        self.db.update_balance(member_id, delta=+earned)
        self.evaluate_tier_upgrade(member_id)
        return earned

Tier Management

Tier thresholds: Bronze (0+ points YTD), Silver (5,000+), Gold (15,000+), Platinum (50,000+). Evaluate tier after every earn transaction. If tier_qualifying_points_ytd crosses a threshold: upgrade immediately. Benefits of the new tier apply to subsequent transactions (not retroactively). Downgrade policy: tier is evaluated annually. At year end, compare tier_qualifying_points_ytd to tier thresholds. If below the current tier threshold: downgrade to the qualifying tier. Give a grace period (30 days notice) before downgrade takes effect. Tier expiry: tier_expires_at = last day of the following calendar year. This prevents members from losing tier status mid-year due to a slow month.

Redemption

Redemption flow: (1) Member selects a RedemptionOption. (2) Validate: member.points_balance >= option.points_required and option.is_active. (3) Create a REDEEM PointTransaction with points_delta = -option.points_required. (4) Update member.points_balance -= option.points_required. (5) Issue the reward (discount code, gift card, partner token). Steps 3-5 must be atomic (database transaction) to prevent double redemption. Idempotency: include a redemption_request_id in the transaction. If the same request_id is submitted twice (network retry), return the existing result. Partial redemption: support redeeming a specific points amount toward a purchase (100 points = $1 off). Cap: cannot redeem more than 50% of order value (business rule).

Points Expiry

Two expiry models: (1) Rolling expiry — points expire 12 months after they were earned. Each PointTransaction has its own expiry_date. (2) Account-level expiry — all points expire if there is no qualifying activity for 12 months. Rolling expiry is more complex but fairer. Implementation: nightly batch job selects EARN transactions where expiry_date < NOW() and the points have not already been expired. For each expired transaction: insert an EXPIRE transaction with points_delta = -original_earned and deduct from balance. Partial expiry: if a member redeemed some points earned in that batch, only expire the remainder. Track which transactions have been expired to avoid double-expiry.

Partner Integration

Partners (airlines, hotels, retail) can issue and accept points. Earn from partner: partner sends a webhook with (member_id, transaction_id, amount). Apply the partner earn rate (may differ from purchase rate). Redeem at partner: partner queries available balance, presents redemption options, completes redemption via API. Partner API uses OAuth 2.0 client credentials. Rate limits per partner to prevent abuse. Exchange rates: points may have different values at different partners (1000 points = $10 at internal store, 1000 points = $8 at partner). Track conversion rates for P&L accounting. Reconciliation: monthly reconciliation with each partner to match issued vs consumed points.

Asked at: Shopify Interview Guide

Asked at: Airbnb Interview Guide

Asked at: DoorDash Interview Guide

Asked at: Stripe Interview Guide

Scroll to Top