Loyalty Program Technical Architecture — Points, Tiers, and the Math Behind Retention
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.