ScaledByDesign/Insights
ServicesPricingAboutContact
Book a Call
Scaled By Design

Fractional CTO + execution partner for revenue-critical systems.

Company

  • About
  • Services
  • Contact

Resources

  • Insights
  • Pricing
  • FAQ

Legal

  • Privacy Policy
  • Terms of Service

© 2026 ScaledByDesign. All rights reserved.

contact@scaledbydesign.com

On This Page

Why Most Loyalty Programs Fail TechnicallyThe Data ModelPoints Accrual EnginePoints RedemptionTier ManagementPoints ExpirationThe Anti-Fraud Layer
  1. Insights
  2. Growth Ops
  3. Loyalty Program Technical Architecture — Points, Tiers, and the Math Behind Retention

Loyalty Program Technical Architecture — Points, Tiers, and the Math Behind Retention

May 11, 2026·ScaledByDesign·
loyaltyecommerceretentionarchitecturedtc

Why Most Loyalty Programs Fail Technically

A DTC brand launched a loyalty program: 1 point per dollar, 100 points = $5 off. Simple. Except: points weren't awarded for subscription orders (webhook integration missed), tier status didn't recalculate on returns (reducing spend didn't reduce tier), and customers could redeem points on a $0 order (free money).

These weren't design problems. They were architecture problems. The business logic was spread across three systems with no single source of truth.

The Data Model

// Core entities for a loyalty system
interface LoyaltyAccount {
  id: string;
  customerId: string;
  pointsBalance: number;       // Current redeemable points
  lifetimePoints: number;      // Total ever earned (for tier calculation)
  lifetimeSpend: number;       // Total qualifying spend
  tier: "bronze" | "silver" | "gold" | "platinum";
  tierExpiresAt: Date;         // Tiers recalculate annually
  createdAt: Date;
}
 
interface PointsTransaction {
  id: string;
  accountId: string;
  type: "earn" | "redeem" | "expire" | "adjust";
  points: number;              // Positive for earn, negative for redeem
  orderId?: string;            // Link to order that generated/used points
  description: string;
  expiresAt?: Date;            // Points can expire
  createdAt: Date;
}
 
interface TierConfig {
  tier: string;
  minLifetimeSpend: number;    // Spend threshold to reach tier
  pointsMultiplier: number;    // Higher tiers earn more points
  perks: string[];
}

Points Accrual Engine

Points should be awarded after an order is confirmed — not when placed, and adjusted on returns:

// Points accrual — triggered by order.completed event
async function awardPoints(orderId: string) {
  const order = await getOrder(orderId);
  const account = await getLoyaltyAccount(order.customerId);
  
  // Calculate qualifying spend (exclude tax, shipping, gift cards)
  const qualifyingSpend = order.items
    .filter(item => !item.isGiftCard)
    .reduce((sum, item) => sum + item.subtotal, 0);
 
  // Apply tier multiplier
  const tierConfig = getTierConfig(account.tier);
  const basePoints = Math.floor(qualifyingSpend); // 1 point per $1
  const earnedPoints = Math.floor(basePoints * tierConfig.pointsMultiplier);
 
  // Record transaction
  await createPointsTransaction({
    accountId: account.id,
    type: "earn",
    points: earnedPoints,
    orderId: order.id,
    description: `Earned ${earnedPoints} points on order ${order.id}`,
    expiresAt: addMonths(new Date(), 12), // Points expire in 12 months
  });
 
  // Update balance
  await updateAccount(account.id, {
    pointsBalance: account.pointsBalance + earnedPoints,
    lifetimePoints: account.lifetimePoints + earnedPoints,
    lifetimeSpend: account.lifetimeSpend + qualifyingSpend,
  });
 
  // Check for tier upgrade
  await evaluateTierStatus(account.id);
}

Points Redemption

Redemption needs careful validation to prevent abuse:

async function redeemPoints(customerId: string, orderId: string, pointsToRedeem: number) {
  const account = await getLoyaltyAccount(customerId);
  const order = await getOrder(orderId);
 
  // Validation rules
  if (pointsToRedeem > account.pointsBalance) {
    throw new Error("Insufficient points balance");
  }
  if (pointsToRedeem < 100) {
    throw new Error("Minimum redemption is 100 points");
  }
  if (pointsToRedeem % 100 !== 0) {
    throw new Error("Points must be redeemed in increments of 100");
  }
 
  const discountAmount = (pointsToRedeem / 100) * 5; // 100 points = $5
 
  // Prevent redeeming more than order value
  const maxRedeemable = Math.min(
    account.pointsBalance,
    Math.floor(order.subtotal / 5) * 100 // Can't make order free
  );
 
  if (pointsToRedeem > maxRedeemable) {
    throw new Error(`Maximum redeemable for this order: ${maxRedeemable} points`);
  }
 
  // Use FIFO: redeem oldest points first (closest to expiration)
  await deductPointsFIFO(account.id, pointsToRedeem);
 
  // Record transaction
  await createPointsTransaction({
    accountId: account.id,
    type: "redeem",
    points: -pointsToRedeem,
    orderId,
    description: `Redeemed ${pointsToRedeem} points for $${discountAmount} off`,
  });
 
  return { discountAmount, remainingBalance: account.pointsBalance - pointsToRedeem };
}

Tier Management

Tiers recalculate based on rolling 12-month spend:

const TIER_THRESHOLDS: TierConfig[] = [
  { tier: "bronze",   minLifetimeSpend: 0,    pointsMultiplier: 1.0, perks: ["1x points"] },
  { tier: "silver",   minLifetimeSpend: 500,  pointsMultiplier: 1.5, perks: ["1.5x points", "free shipping"] },
  { tier: "gold",     minLifetimeSpend: 1500, pointsMultiplier: 2.0, perks: ["2x points", "free shipping", "early access"] },
  { tier: "platinum", minLifetimeSpend: 5000, pointsMultiplier: 3.0, perks: ["3x points", "free shipping", "early access", "VIP support"] },
];
 
async function evaluateTierStatus(accountId: string) {
  const account = await getLoyaltyAccount(accountId);
  
  // Calculate rolling 12-month spend
  const twelveMonthSpend = await calculateSpendInPeriod(
    account.customerId,
    subMonths(new Date(), 12),
    new Date()
  );
 
  // Determine correct tier
  const newTier = TIER_THRESHOLDS
    .filter(t => twelveMonthSpend >= t.minLifetimeSpend)
    .pop(); // Highest qualifying tier
 
  if (newTier && newTier.tier !== account.tier) {
    const isUpgrade = TIER_THRESHOLDS.findIndex(t => t.tier === newTier.tier) >
                      TIER_THRESHOLDS.findIndex(t => t.tier === account.tier);
 
    await updateAccount(accountId, {
      tier: newTier.tier,
      tierExpiresAt: addMonths(new Date(), 12),
    });
 
    // Notify customer
    if (isUpgrade) {
      await sendTierUpgradeEmail(account.customerId, newTier);
    }
    // Note: tier downgrades happen silently on annual recalculation
  }
}

Points Expiration

Points that expire are revenue you don't have to give back. But expiration must be transparent:

// Run nightly: expire old points and warn customers
async function processPointsExpiration() {
  // Warn customers 30 days before expiration
  const expiringIn30Days = await getExpiringPoints(addDays(new Date(), 30));
  for (const account of expiringIn30Days) {
    await sendExpirationWarningEmail(account.customerId, account.expiringPoints);
  }
 
  // Expire points past their date
  const expired = await getExpiredPoints(new Date());
  for (const transaction of expired) {
    await createPointsTransaction({
      accountId: transaction.accountId,
      type: "expire",
      points: -transaction.remainingPoints,
      description: `${transaction.remainingPoints} points expired`,
    });
  }
}

The Anti-Fraud Layer

Loyalty programs are targets for abuse:

Fraud patterns to guard against:
  → Self-referral loops (same person, different emails)
  → Buy-return-keep-points (earn on purchase, return product, keep points)
  → Point transfer exploitation (earn on high-tier account, redeem on another)
  → Bot-driven point accumulation (automated purchases for points)

Mitigations:
  → Deduct points on returns (link earn transactions to orders)
  → Rate limit point earnings (max 10K points/day per account)
  → Flag accounts with > 50% return rate
  → Require email verification for new accounts
  → Monitor for multiple accounts with same address/payment method

A loyalty program is a financial system. Treat it like one — with proper transaction records, validation, fraud detection, and reconciliation. The brands that get this right turn one-time buyers into lifetime customers. The ones that don't create a liability on their balance sheet.

Previous
Engineering OKRs That Actually Drive Results
Insights
Loyalty Program Technical Architecture — Points, Tiers, and the Math Behind RetentionThe Shopify Plus Migration That Saved $400K/YearProduct Page Conversion Engineering — The Technical Optimizations That Move the NeedleThe Headless Commerce Migration Playbook — From Monolith to ComposableThe Subscription Box Tech Stack That Scales Past $10M ARRThe Post-Purchase Email Sequence That Drives 40% Repeat RevenueYour Post-Purchase Experience Is Leaving $2M on the TableYour Attribution Is Lying to You — Here's How to Fix ItThe DTC Tech Stack That Actually Scales Past $10MSubscription Churn Is a Systems Problem, Not a Marketing ProblemLifecycle Automation That CompoundsThe Checkout Optimization Playbook That Added $2M in RevenueWhy Your Shopify Store Breaks During Every SaleWhy Your Loyalty Program Isn't Working (And What to Build Instead)COGS Reporting Shouldn't Take 5 DaysHeadless Commerce: When It's Worth It and When It's a TrapThe Inventory Forecasting System That Stopped Our Client From OversellingPayment Processing Architecture for High-Volume Merchants

Ready to Ship?

Let's talk about your engineering challenges and how we can help.

Book a Call