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.
{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How do you implement a points ledger to ensure consistency?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “The points ledger is an append-only log of PointTransaction records. Each transaction has: member_id, txn_type (EARN/REDEEM/EXPIRE/ADJUST), points_delta (positive or negative), source_reference_id (the order or redemption ID), and expiry_date. The member’s balance is a cached sum of all transactions — it can always be recomputed by summing points_delta across all transactions for that member. On every earn or redeem: INSERT the transaction AND UPDATE the cached balance atomically in a database transaction. The unique constraint on source_reference_id prevents duplicate points for the same purchase (idempotency). If the balance cache diverges from the transaction sum (detected by a nightly reconciliation job), the transaction log is the source of truth.”
}
},
{
“@type”: “Question”,
“name”: “How do you implement tier qualification and upgrade logic?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Tier qualification uses lifetime points earned in a calendar year (tier_qualifying_points_ytd), separate from redeemable balance. After every EARN transaction: tier_qualifying_points_ytd += earned_points. Check if the new YTD crosses a tier threshold (e.g., Silver=5,000, Gold=15,000, Platinum=50,000). If so, upgrade immediately and set tier_expires_at = end of the following calendar year. This gives members a full year to requalify. On December 31: run a batch job, compare each member’s YTD to their current tier threshold. If below: schedule a downgrade with 30 days notice (send email, show banner). Execute downgrade on Jan 31. This grace period prevents immediate downgrade after the year resets.”
}
},
{
“@type”: “Question”,
“name”: “How do you handle points expiry fairly?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Rolling expiry: each PointTransaction carries its own expiry_date (e.g., 12 months from the earn date). A nightly job selects EARN transactions where expiry_date = redemption cost. (3) BEGIN TRANSACTION: INSERT PointTransaction (REDEEM, -cost), UPDATE member points_balance -= cost WHERE points_balance >= cost. (4) If the UPDATE affects 0 rows (another transaction reduced the balance below cost concurrently): ROLLBACK. (5) COMMIT and issue the reward. The WHERE points_balance >= cost clause is the optimistic concurrency check — it fails safely if a concurrent redemption drained the balance. For additional safety: use SELECT FOR UPDATE on the member row to pessimistically lock before checking balance. Include a redemption_request_id in the transaction for idempotency on retries.”
}
},
{
“@type”: “Question”,
“name”: “How do you integrate third-party partners into a loyalty program?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Partner integration has two directions: earn (partner sends points to us) and redeem (member spends points at a partner). For earning: partners POST to our API with (member_id or partner_member_id, transaction_id, amount, earn_type). We validate the partner OAuth token, look up the member, apply the partner earn rate, insert the PointTransaction, and return the earned points. The transaction_id is the idempotency key. For redemption: the partner queries our API for the member’s available balance, presents redemption options, and calls our redeem endpoint. We deduct the points and return a redemption code or confirmation. Points may have different values at partners (define partner-specific exchange rates). Monthly reconciliation: compare our issued transaction log with the partner’s log; resolve discrepancies via adjustments.”
}
}
]
}
Asked at: Shopify Interview Guide
Asked at: Airbnb Interview Guide
Asked at: DoorDash Interview Guide
Asked at: Stripe Interview Guide