Promotions and loyalty
Discount rules, stacking, best-price evaluation, cashback, tiers and referrals without losing money.
In 30 seconds
Promotions and loyalty programmes are how a shop brings people back: discounts, free delivery, gifts, cashback, levels and rewards for inviting friends. Under the hood they are a rule engine that decides which offers apply to a cart, how they combine, and what the customer finally pays. The hard parts are clear stacking rules, a predictable best-price result, and protection against abuse.
Why I use it
- I use rule-based promotion engines because I have built them for several shops: restaurant ordering, beauty products and a wine club all needed discounts that marketing can configure without a developer.
- Marketing teams change offers weekly. If every campaign needs a code change, the shop either stagnates or ships bugs.
- Customers get angry when two valid offers produce a worse price than one. Evaluating combinations and picking the best outcome prevents that.
- Loyalty money is real money. Wallets, cashback and referrals need the same care as payments: ledgers, expiry rules and fraud checks.
How it works
01Anatomy of a promotion rule
Every promotion has three parts: conditions, an action and limits. Conditions decide whether it applies: minimum cart total, specific products or categories, customer segment or loyalty level, first order only, channel (web or app), date range, day of week. The action is what it gives: percentage off, fixed amount off, a fixed price, buy X get Y, a free gift item, or free delivery. Limits keep it under control: total uses, uses per customer, maximum discount, budget. Promotions can be automatic (applied when conditions match) or manual (activated by a code or a button). Storing rules as data — rows with typed conditions and actions — instead of code is what lets marketing work without developers.
- Percentage: 10% off the cart or selected lines, optionally capped.
- Fixed: 5.00 off orders above 30.00.
- First order, birthday, loyalty level: conditions on the customer, not the cart.
- Gift item or free delivery: actions that change lines or fees, not just the total.
02Stacking, priority and exclusivity
Stacking means combining several promotions on one order. Without rules, discounts compound into losses. A practical model: each promotion has a priority and a stacking mode — exclusive (cannot combine with anything), combinable with any, or combinable only within its group. Decide the order of application too: percentage discounts usually apply before fixed ones, and line discounts before cart discounts, because the order changes the result. Decide whether a discount applies to the price before or after other discounts, and never let the total drop below zero or below a configured margin. Write these rules down as a short table that marketing approves; then implement exactly that table and test it.
03Best price for the customer
When several offers are eligible but cannot all stack, which one wins? The friendliest answer is: whichever combination gives the customer the lowest total. The engine first filters eligible promotions, then builds the valid combinations allowed by the stacking rules, simulates each on a copy of the cart, and picks the cheapest result — with a deterministic tie-breaker such as higher priority, then older promotion. The number of combinations grows fast, so keep it bounded: exclusive offers form one-item scenarios, and combinable ones usually stack greedily within their group. Return not only the total but an explanation per line ("Summer sale −10%"), which shows on the receipt and makes support questions easy.
type Cart = { lines: { id: string; price: number; qty: number }[] };
type Promo = { id: string; priority: number; exclusive: boolean; apply: (c: Cart) => number };
// Returns the discount combination with the lowest final total
export function bestScenario(cart: Cart, eligible: Promo[]) {
const subtotal = cart.lines.reduce((s, l) => s + l.price * l.qty, 0);
const stackable = eligible.filter((p) => !p.exclusive).sort((a, b) => b.priority - a.priority);
const scenarios: Promo[][] = [stackable, ...eligible.filter((p) => p.exclusive).map((p) => [p])];
let best = { promos: [] as Promo[], total: subtotal };
for (const promos of scenarios) {
const discount = promos.reduce((sum, p) => sum + p.apply(cart), 0);
const total = Math.max(0, subtotal - discount);
if (total < best.total) best = { promos, total };
}
return best; // ties keep the earlier, higher-priority scenario
}
04Wallets, cashback and loyalty tiers
Cashback returns a percentage of an order to a customer wallet as store credit. Treat the wallet as a ledger: never store only a balance — store every credit and debit with a reason and a reference to the order, and derive or verify the balance from those rows. Credit cashback only after the order is delivered or the return window passes, otherwise people buy, collect and refund. Spending from the wallet is a payment method at checkout, applied after promotions. Loyalty tiers (for example Bronze, Silver, Gold) reward customers by spend or points over a period. Define how a customer moves up, when they can drop down, and whether there is a grace period. Points and balances often expire after inactivity; send a reminder before they do.
- Ledger rows: credit, debit, expire, manual adjustment — each with who and why.
- Manual adjustments by staff deserve an audit log, and large ones a second approval.
- Recalculate tiers on a schedule and sync them into customer segments.
05Referrals, vouchers and fraud checks
A referral programme rewards a customer for inviting friends: each user gets a code or link, the friend signs up through it, and both get a reward after the friend's first qualifying order. Track clicks and sign-ups, and for mobile apps use deferred deep links so the code survives the app install. Settle rewards in a delayed batch job, not instantly, so you can cancel rewards for refunded orders. Referrals and vouchers attract abuse: self-referrals with a second account, many accounts on one phone or card, shared codes on coupon sites. Basic checks catch most of it: same device, phone, card fingerprint or address as the referrer, order refunded, too many sign-ups from one code in a short time. Vouchers should be random, long enough to resist guessing, rate-limited and single-use when they carry value.
Use it when
- Marketing runs frequent campaigns and needs to configure them without developers.
- The business relies on repeat customers: food ordering, beauty, pet supplies, coffee, wine clubs.
- You need several reward types — discounts, gifts, cashback, tiers, referrals — that must interact predictably.
Skip it when
- A shop with one or two simple coupon codes; the platform's built-in coupons are enough.
- When margins are thin and the team cannot measure promotion effect — discounts without analytics just give money away.
- Avoid building a points currency if a simple percentage discount achieves the same goal.
Alternatives
- Platform discounts (Shopify, WooCommerce)
Good for standard coupons and automatic discounts; limited when stacking or custom conditions get complex.
- Talon.One / Voucherify
Hosted promotion and loyalty engines with rule builders and APIs; fast to adopt, recurring cost per volume.
- Loyalty SaaS (Smile.io, Yotpo)
Ready-made points and referral widgets; less control over rules and data.
Best practices
- Store promotions as data with typed conditions, actions and limits.
- Write explicit stacking and priority rules and get business sign-off.
- Evaluate combinations and choose the best customer total deterministically.
- Show a per-line explanation of every applied discount.
- Keep wallets and points as ledgers; never only a balance column.
- Delay cashback and referral rewards until orders cannot be refunded.
- Log and review manual adjustments; require a second approval for large ones.
Common mistakes
- Hard-coding campaigns, so every promotion is a deployment.
- Letting discounts compound beyond the order value or below cost.
- Different results on cart page and checkout because the engine runs in two places.
- Crediting cashback on payment, then losing money on refunds.
- Referral programmes without fraud checks, attracting farms of fake accounts.
Deep dive
06Engine architecture: a pure pricing pipeline
The most reliable promotion engines are pure functions: given a cart, a customer context, a time and a list of active promotions, they return a priced cart and an explanation, with no database writes. That makes them easy to test with exact amounts, safe to run on every cart change, and identical on the cart page, checkout and admin previews. Structure the pipeline in stages: load candidate promotions (pre-filtered by date, channel and status with an index), evaluate conditions, build scenarios, apply actions on a copy of the cart, pick the best, and produce line-level adjustments. Side effects — counting a use, debiting a voucher, crediting cashback — happen later, only when the order is created or completed, inside the order transaction.
type Ctx = { now: Date; customerId?: string; segment: string[]; channel: "web" | "app" };
type Adjustment = { lineId?: string; promoId: string; amount: number; label: string };
// Pure: same input → same output, no I/O
export function priceCart(cart: Cart, ctx: Ctx, promos: Promo[]) {
const eligible = promos.filter((p) => p.isActive(ctx.now) && p.matches(cart, ctx));
const best = bestScenario(cart, eligible);
const adjustments: Adjustment[] = best.promos.flatMap((p) => p.adjustments(cart));
return { total: best.total, adjustments };
}
07Usage limits under concurrency
"First 500 customers" and "one use per customer" sound simple until 2,000 people check out in the same minute. Counting uses with a SELECT COUNT and then inserting is a race: everyone sees 499. Enforce limits with the database. For a global cap, keep a counter row and increment it conditionally (UPDATE promotions SET used = used + 1 WHERE id = :id AND used < max_uses) in the order transaction. For per-customer limits, insert a redemption row with a unique constraint on (promotion_id, customer_id). Release the use if the order is cancelled before payment. Budget caps work the same way with amounts instead of counts. Validate again at checkout, and accept that the cart page may show a promotion that is gone a second later; tell the customer clearly.
08Measuring whether a promotion works
A promotion is a cost; the question is what it bought. Track per promotion: orders, discount given, revenue, margin after discount, new versus returning customers, and redemption rate. Raw numbers mislead, because many customers would have bought anyway. The honest measure is incrementality: compare a randomly chosen group that receives the offer with a holdout group that does not, and look at the difference in orders and margin. Even a simple 90/10 split on a newsletter coupon gives better answers than any dashboard. For loyalty programmes, watch repeat purchase rate and time between orders by tier, plus the liability of outstanding points or wallet balances, which finance may need to treat as a deferred cost.
- Store the applied promotion IDs and amounts on order lines for reporting.
- Report discount cost as a percentage of revenue per campaign.
- Keep a holdout group for automated offers such as win-back coupons.
09Points and wallet accounting edge cases
Ledger-based wallets raise edge cases worth deciding in advance. Expiry: with first-in-first-out spending, debits consume the oldest credits first, so an expiry job knows exactly which amounts remain from each credit; store remaining amounts per credit lot to make that cheap. Partial refunds: if an order paid partly with wallet credit is partly refunded, decide whether credit or card is returned first, and reverse cashback proportionally. Concurrency: debits must lock the customer's wallet row or use a conditional update so two parallel checkouts cannot spend the same balance. Currency: a wallet should hold one currency. Legally, in some countries store credit or points can count as stored value or e-money, with consumer and accounting rules, so check before letting customers top up with real money.
Real-world scenarios
A coupon leaks to a deal site
Problem
A 30% influencer code meant for a small audience appears on a coupon aggregator. Within hours it is used thousands of times and margin on the day turns negative.
How to solve it
Pause the promotion from the admin immediately — every promotion needs an active switch that takes effect without a deployment. Orders already placed are usually honoured to protect trust, unless terms allow cancellation. Then fix the design: give influencer campaigns a total use cap and a budget cap, enforce them atomically, and add a per-customer limit with a unique redemption constraint. For targeted offers, use unique single-use codes generated per recipient instead of one shared code. Add an alert on redemption velocity, for example more than three times the expected hourly uses, so the next leak is caught in minutes.
Marketing wants "buy two, get the cheapest free"
Problem
The current engine only supports percentage and fixed-amount discounts on the whole cart. The new offer must work across a category with mixed prices and quantities.
How to solve it
Add a new action type rather than a special case. Expand eligible lines into units (a line with quantity 3 becomes three units), sort them by price, and for every group of the required size mark the cheapest unit as discounted at 100%. Return adjustments per line, so a line with two units may get one free unit. Decide and document the rules: whether the cheapest or most expensive group gets the benefit, whether other discounts apply to the free unit, and how a partial return works. Add tests for mixed prices, odd quantities and refunds, and show "1 free" on the receipt line.
Cashback balances grow faster than expected
Problem
Finance notices that unused wallet balances have tripled in six months, and some customers have large balances from orders that were later returned.
How to solve it
First audit: rebuild balances from the ledger and find credits whose source orders were refunded without a matching reversal. Fix the flow so cashback is credited only after the return window closes, and a refund automatically writes a reversing ledger entry. Introduce expiry for inactive balances with a reminder sent, say, 14 days before, and record expiry as its own ledger entry type. Report outstanding balance as a liability each month so finance can plan for it. If balances must be clawed back from customers who already spent them, allow negative balances to be netted against future credits rather than charging customers.
Cheat sheet
- Global use cap, atomically
UPDATE promotions SET used = used + 1 WHERE id = :id AND used < max_uses;- One use per customer
CREATE UNIQUE INDEX redemptions_uq ON promotion_redemptions (promotion_id, customer_id);- Wallet balance from the ledger
SELECT COALESCE(SUM(amount), 0) FROM wallet_entries WHERE customer_id = :id;- Lock the wallet before a debit
SELECT id FROM wallets WHERE customer_id = :id FOR UPDATE;- Random voucher code (Node)
crypto.randomBytes(8).toString("base64url").toUpperCase() // ~64 bits of entropy- Rate-limit voucher attempts (Laravel)
Route::post('/cart/voucher', ApplyVoucher::class)->middleware('throttle:10,1');- Active promotions index
CREATE INDEX promotions_active_idx ON promotions (status, starts_at, ends_at);- Settle referral rewards hourly
Schedule::job(new SettleReferralRewards)->hourly()->withoutOverlapping();
In my real projects
- On a restaurant-chain platform I built a promotion engine of about 1,335 lines: regular, first-order, loyalty-level and cashback promotions, manual and automatic, gift items, with stacking rules and simulation of combinations to choose the best total; plus a cashback wallet with tiered percentages and voucher redemption.
- On a beauty e-commerce shop a similar engine of about 1,294 lines handles regular, first-order, loyalty-tier, cashback and free-gift promotions with best-scenario selection and scheduled expiry, tied to a store-credit wallet.
- On a pet-supplies platform I built a referral programme with invite codes, click and share tracking, deferred deep links for app installs, fraud checks and hourly reward settlement; and a wine club runs on bottle-registration codes, points and five loyalty tiers.
Questions & answers
Q1How do you decide which promotion applies when several are eligible?
First, I filter promotions whose conditions match the cart and customer. Then I build only the combinations allowed by the stacking rules: exclusive promotions alone, combinable ones together in priority order. I simulate each scenario on a copy of the cart and choose the lowest final total, with a deterministic tie-breaker such as priority. The same engine runs for the cart page and checkout, so the customer never sees a different price at payment. The result includes a per-line explanation for receipts and support.
Q2Why model a wallet as a ledger instead of a balance column?
A balance column tells you what someone has, but not why. With a ledger, every credit, debit, expiry and manual adjustment is a row with an amount, reason, reference and author. You can explain any balance, reverse a specific transaction, expire old credits by age, and audit staff actions. Concurrency is also safer: you insert rows instead of overwriting a number. A cached balance column is fine for speed, but it should be derivable from the ledger and verified regularly.
Q3How do you stop referral fraud?
Rewards are paid in a delayed settlement job, only after the friend's first order is completed and past the refund window. Before paying, checks compare the new account with the referrer: same device, phone number, payment card fingerprint, delivery address or IP pattern. Velocity limits flag codes with too many sign-ups in a short time. Suspicious cases are held for review instead of rejected automatically. Codes can be revoked, and refunded orders reverse rewards through the ledger.
Q4Percentage first or fixed amount first?
It changes the result, so it must be a deliberate rule. On a 100.00 cart, 10% off and then 5.00 off gives 85.00, while 5.00 off and then 10% gives 85.50 — so applying the percentage first is cheaper for the customer. Many shops apply line-level discounts first, then cart-level, then percentage before fixed. What matters most is consistency: the same order everywhere, documented, and covered by tests with exact amounts.
Q5How would you design loyalty tiers?
Define the metric (spend or points) and the window (rolling 12 months or calendar year), the thresholds, and the benefits per tier, such as a higher cashback percentage. Then define the uncomfortable parts: when a customer is downgraded, whether there is a grace period or a floor, and what happens to refunds. A scheduled job recalculates tiers and records changes, and tier membership feeds customer segments for campaigns. Customers should always see their progress to the next tier.
Senior-level questions
S1Exhaustive best-price search or greedy application — how do you choose?
Exhaustive search over all combinations gives the truly best price but grows exponentially: twenty combinable promotions mean about a million subsets. Greedy application in priority order is fast and predictable but can miss a better combination. In practice I combine both: stacking groups and exclusivity reduce the space to a handful of scenarios, each scenario is applied greedily within itself, and the engine compares scenario totals. I cap the number of evaluated scenarios and log when the cap is hit. The engine must also be deterministic, with stable tie-breakers, because a price that changes between two identical requests destroys trust. Marketing usually values predictability and explainability more than squeezing out the mathematically optimal discount.
S2Should a promotion engine be built in-house or bought?
Buy when the needs are mainstream and speed matters: hosted engines provide rule builders, budgets, codes, analytics and A/B testing, and the team avoids maintaining a complex subsystem. The costs are per-volume pricing, an extra network call in the pricing path — so you need a fallback when it is slow — and limits when rules depend on your own data, such as delivery slots or branch menus. Build when promotions are a core differentiator, when rules depend deeply on domain data, or when the price must be computed offline or in milliseconds. If building, keep it as a pure, well-tested module with promotions as data, so the decision stays reversible later.
S3How do you keep a loyalty programme from becoming a financial liability nobody understands?
Treat points and wallets as money from day one. Every balance is a ledger of credits and debits, so finance can see issued, redeemed, expired and outstanding amounts by month. Define a value per point and an expiry policy, and report the outstanding liability regularly; under common accounting standards, loyalty rewards granted with a sale may defer part of that sale's revenue, so involve the accountant early. Limit how rewards are earned on discounted or refunded orders. Make tier rules explicit, including downgrades, so benefits do not ratchet up forever. Finally, run the programme against a control group occasionally: if members do not buy more than similar non-members, the programme is just a discount with extra steps.
Glossary
- Stacking
- Applying more than one promotion to the same order or line.
- Exclusive promotion
- An offer that cannot be combined with any other promotion.
- Scenario
- One allowed combination of promotions, simulated on a cart copy to compare totals.
- Adjustment
- A discount amount attached to a specific line or the order, with the promotion that caused it.
- Cashback
- A percentage of an order returned to the customer as store credit, usually after delivery.
- Credit lot
- One wallet credit tracked with its remaining amount, so expiry and FIFO spending are precise.
- Incrementality
- The extra sales a promotion truly causes, measured against a group that did not receive it.
- Holdout group
- Customers deliberately excluded from an offer to measure its real effect.
- Deferred deep link
- A link whose data survives an app install, so the app opens with the referral code after first launch.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.