A user earns 500 points on a purchase. Then returns the purchase. The points balance drops by 500. But what if 200 of those points were already spent on a voucher? What if the return happens after the points expired? What if the purchase earned bonus points from a campaign that ended yesterday – does the clawback take base points or bonus points first?
The promocode engine answered one question: does this code apply? Yes or no, fast, at checkout. The loyalty engine answers a harder one: what does this user have, right now, and is it correct?
Two currencies, two lifecycles
The system has two units. Points determine tier – Silver, Gold, Platinum, Diamond. They accumulate with every transaction, they expire after a fixed window, and if enough expire, the user drops a tier. Stamps are transactional currency – earn them, spend them in the Reward Center, they’re gone. Points are a score. Stamps are money.
The split was deliberate. A single currency that both gates status and buys rewards creates a tension: spending for rewards hurts your tier. Separating them lets each lifecycle do its own thing. Points age out on a calendar. Stamps have a redemption window. The rules don’t collide because the currencies don’t mix.
Neither is a counter. Both are ledgers.
type Entry struct {
UserID string
Amount int // positive for earn, negative for spend/clawback/expire
Type string // "earn_base", "earn_bonus", "redeem", "expire", "clawback"
SourceID string // order_id, campaign_id, reward_id
ExpiresAt time.Time
CreatedAt time.Time
}
func Balance(entries []Entry) int {
var sum int
for _, e := range entries {
sum += e.Amount
}
return sum
}
Every operation inserts a row. Nothing updates a balance column. When a return comes in, the engine inserts a clawback row. When points expire, a nightly job inserts expiry rows. The balance is SUM(amount) across all entries. The balance is a view. The ledger is the source of truth.
The clawback logic is where it stops being arithmetic. If a user earns 500 base points and 200 bonus points from a campaign, then returns the purchase, the clawback has to decide: which points get taken back first? Base points – because bonus points may have time-limited earn conditions that have already passed. But if the user already spent 300 base points on a voucher, the clawback has to reach into the bonus pool. If bonus points expired last week, the clawback has to leave a negative balance that the user earns out of. Each edge case was a support ticket that became a code path.
The tier machine
Tiers are a state machine with thresholds. Above 1,000 lifetime points: Silver. Above 5,000: Gold. Above 20,000: Platinum. Above 50,000: Diamond. Higher tiers unlock larger free-shipping caps and better cashback rates.
The machine has two directions. Earn enough points and you graduate upward: notification, celebration, a new badge. But points expire. If enough expire that the lifetime total drops below a threshold, you demote.
The design argument was about the grace period. Thirty days? Ninety? Until end of quarter? The answer that shipped: long enough that a demotion never surprises an active user. If you haven’t earned enough points in three months to cover what expired, the tier stopped reflecting your behavior. The engine just catches up.
var thresholds = []struct {
Tier string
MinPts int
}{
{"silver", 1000},
{"gold", 5000},
{"platinum", 20000},
{"diamond", 50000},
}
func ComputeTier(lifetimePoints int) string {
tier := "classic"
for _, t := range thresholds {
if lifetimePoints >= t.MinPts {
tier = t.Tier
}
}
return tier
}
The threshold table is the simplest code in the system. One loop, no branches worth naming. The complexity is elsewhere – in the expiry job that fires at midnight, in the clawback that unwinds three months of subsequent earns, in the grace-period timer that starts when the balance first dips.
Partial progress
The promocode engine was all-or-nothing. A condition tree evaluates, the answer is yes or no. Quests don’t work that way. Follow five unique merchants. Visit three storefronts. The evaluator has to answer “how far along?” at every event, not just “done yet?”
type Quest struct {
ID string
TargetAction string // "merchant.store.follow"
RequiredCount int // 5
UniqueCheck bool // true
Rewards map[string]int // {"points": 50, "stamps": 2}
}
type Progress struct {
QuestID string
Completed int
Seen map[string]bool // merchant IDs for uniqueness
Status string // "active", "completed", "claimed"
}
The evaluator receives events from the Kafka pipeline. A store-follow event arrives. It looks up active quests matching that action, checks the uniqueness filter, increments progress, and writes back. When completed == required_count, the quest flips and rewards fire.
Most of the work is in the edge conditions. Does unfollowing a store decrement progress? No. The quest requires following, not staying followed. Does following the same store twice count? No. The uniqueness check catches it. Does a store follow from six months ago count toward a quest launched today? No. Progress windows reset per quest activation.
The evaluator is stateful in a way the promocode engine never was. Every event mutates progress. The cache has to be right, or the user sees “4 of 5” when they’ve actually finished.
The cat doesn’t sleep
A quest awards 50 points for following five unique stores. It takes two minutes to complete honestly. It takes less if you script it.
Users found the pattern within hours. Follow 50 stores programmatically, claim rewards, unfollow, repeat. The first fix: a uniqueness constraint per quest: each store ID counts once. Users adapted: follow different stores, still programmatic. The second fix: a velocity limit: max 20 store-follows per hour. Users adapted: pace the script to 20 per hour, let it run overnight. The third fix: a sliding window, not a calendar-hour reset. The fourth: pattern detection: accounts with a store-follow-to-purchase ratio of infinity get flagged for review.
Each fix was reactive. The engine didn’t predict the abuse vector. It couldn’t. The space of behaviors users will try is larger than the space of rules you can predefine. The engine gets better one patch at a time, each patch a response to a behavior someone already demonstrated.
The abuse rules table was the only part of the system that grew without a plan. Every entry a scar.
The boring part
If the promocode engine’s thesis was “the operators stay boring,” the loyalty engine’s is: the state machine is simple. The ledger is append-only. The complexity lives in time, and in users who are smarter than your rules.
The points math is arithmetic. The tier thresholds are a lookup table. The quest evaluator is a counter with a uniqueness filter. None of the individual pieces are hard.
What’s hard: a clawback that has to unwind three months of subsequent earns. A tier demotion that fires during a flash sale. A user who earns, spends, returns, and re-earns faster than the grace-period timer can tick. An abuse pattern that changes shape between deploys.
The engine stays small. The data grows. The cat-and-mouse doesn’t end.
