Back to BlogIndustry Guides

A Coupon and Promotions Engine: Rules, Abuse and Testing

Rupak Amin

Founder & Lead Engineer, RAITHub

10 min read

A coupon and promotions engine is a rule evaluator: each promotion has conditions (cart total, items, customer, dates), a discount, and limits (per code, per customer, total). It runs on every cart, applies the strongest valid set under your stacking rules, and enforces limits inside the order transaction. The hard parts are stacking, exclusions, and usage limits that leak when two orders redeem a single-use code at the same moment.

If you would rather have it built for you, see how RAITHub would build this below.

Why not just add a discount with an if-statement?

Because the second promotion arrives within a week. "10% off orders over 50", "free shipping for new customers", "buy two get one", a single-use welcome code: each new rule tangles with the last, and the questions multiply. Do they stack? Which wins when two apply? Does the free-shipping promotion see the discounted total or the original? An engine answers these the same way every time, from data, instead of from a growing pile of conditionals nobody can reason about.

Part of a promotionWhat it holdsExample
ConditionsWhat must be true for it to applyCart over 50, contains a category, customer is new, within the date window
DiscountWhat it changesPercentage off, fixed amount off, free shipping, lowest-priced item free
ScopeWhat the discount applies toWhole order, a line, a category
LimitsHow often it can be usedOnce per customer, 1,000 total, one redemption per code
StackingHow it combines with othersExclusive, or stackable with named groups

How should stacking and exclusions work?

Decide one default and make exceptions explicit. The safe default is exclusive: at most one order-level discount applies, and the engine picks the one that gives the shopper the lowest valid price. Allow stacking only between promotions you tag as compatible, for example a product discount plus free shipping. Model it with a small set of rules rather than free-for-all combination:

  • Exclusive by default: two order-level percentage codes never both apply.
  • Stacking groups: promotions in different groups may combine; two in the same group may not.
  • Exclusions: a promotion can name categories, products or already-discounted items it refuses to touch, so a clearance item is not discounted twice.
  • Order of operations: fix whether percentage discounts apply before or after fixed-amount ones, because the result differs. Pick one and test it.

Evaluate the engine on the server at checkout, never trust a discount the browser calculated, and recompute the final price from the cart and the catalogue before you charge. That is the same rule as any price-sensitive path: the client proposes, the server decides.

What does the rule model look like?

CREATE TABLE promotions (
  id             bigserial PRIMARY KEY,
  code           text UNIQUE,                      -- null for automatic promotions
  conditions     jsonb NOT NULL,                   -- {minTotal, categories, newCustomer, ...}
  discount_type  text  NOT NULL CHECK (discount_type IN ('percent','fixed','free_shipping')),
  discount_value numeric(12,2) NOT NULL DEFAULT 0,
  stacking_group text,                             -- null = exclusive
  starts_at      timestamptz NOT NULL,
  ends_at        timestamptz NOT NULL,
  usage_limit    integer,                          -- total redemptions, null = unlimited
  per_customer   integer,                          -- redemptions per customer, null = unlimited
  used_count     integer NOT NULL DEFAULT 0 CHECK (used_count >= 0)
);

CREATE TABLE promotion_redemptions (
  promotion_id bigint NOT NULL REFERENCES promotions(id),
  order_id     bigint NOT NULL REFERENCES orders(id),
  customer_id  bigint NOT NULL REFERENCES customers(id),
  created_at   timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (promotion_id, order_id)
);

Putting conditions in jsonb keeps the schema stable while the kinds of rule grow, and the evaluator reads them into typed code. The promotion_redemptions table is the record of who used what; it is also how you enforce the per-customer limit and how you reverse a redemption when an order is cancelled or returned.

Why do usage limits leak, and how do you stop it?

Because checking a limit and recording a redemption are two steps, and two orders can slip between them. A single-use code read as "0 used" by two carts at once passes the check twice, and both redeem it. The fix is to make the check and the increment one atomic operation guarded by the database, not the application.

-- Redeem only if the total limit is not yet reached. One statement: the
-- WHERE clause and the UPDATE happen together, so two orders cannot both win.
UPDATE promotions
   SET used_count = used_count + 1
 WHERE id = $1
   AND (usage_limit IS NULL OR used_count < usage_limit)
RETURNING used_count;
-- Zero rows returned means the limit was already reached: reject the coupon.

Run this inside the order transaction, alongside inserting the promotion_redemptions row, so a redemption and its count move together. Enforce the per-customer limit with the redemption table and a lock on the customer's recent redemptions, or a unique constraint when the rule is strictly once per customer. The underlying concurrency problem, and the lock patterns that solve it, are the same ones in preventing overselling inside the order transaction.

How do you test for coupon abuse?

Two layers. Unit tests pin the rules: a stacking case, an exclusion case, an expired code, a minimum-not-met case. Then an end-to-end test proves the limit holds when the browser tries to break it. The most common abuse is redeeming a single-use code more than once, so fire concurrent redemptions and assert only one succeeds.

import { test, expect, request } from '@playwright/test'

// A single-use code must survive a race: fire many redemptions at once,
// expect exactly one to succeed and the rest to be rejected.
test('single-use coupon is redeemed only once under concurrency', async ({ playwright }) => {
  const api = await request.newContext({ baseURL: process.env.BASE_URL })
  const attempts = Array.from({ length: 20 }, () =>
    api.post('/api/cart/apply-coupon', { data: { code: 'WELCOME-ONCE', cartId: 'seeded-cart' } }),
  )
  const results = await Promise.all(attempts)
  const ok = results.filter((r) => r.ok()).length
  expect(ok).toBe(1)
})

test('expired coupon is rejected at checkout, not just hidden', async ({ page }) => {
  await page.goto('/cart')
  await page.getByLabel('Discount code').fill('EXPIRED2025')
  await page.getByRole('button', { name: 'Apply' }).click()
  await expect(page.getByText(/code has expired|not valid/i)).toBeVisible()
})

Other abuse paths worth a test: a per-customer code redeemed from two accounts that share a card, a code applied then the qualifying item removed before payment (the engine must re-evaluate), and a returned order that should give back the redemption. Testing the money and limit paths is the same discipline as the marketplace QA checklist and testing payments and webhooks.

Buy, build or hire?

OptionChoose this whenThe catch
Platform promotions (Shopify, WooCommerce, a promotions app)Standard percentage, fixed and shipping discounts on a hosted storeComplex stacking, bespoke conditions or your own loyalty tie-in often need an app or custom work anyway
A rules library or config for an existing cartYou have a custom cart and need a structured engine, not a vendorYou still own concurrency, exclusions and the tests
Custom buildPromotions drive real revenue, stacking is specific, or codes tie into store credit and loyaltyYou own the evaluator and the test suite that keeps abuse out

How long does it take to build yourself, and what is the risk?

For an experienced developer adding a structured engine to an existing cart, our estimate is 2 to 4 weeks: the rule model, the evaluator, atomic usage limits, redemption reversal on returns, and the test suite. The main risk is concurrency: a limit that passes every single-threaded test still leaks under load, so write the race test before you trust the limit. The second risk is scope creep in the rules; agree the kinds of promotion you support before building, or the engine never stops growing.

Why RAITHub for this

  • Tier and rule pricing in production. Sundor Skin, a B2B wholesale platform RAITHub built, computes tier pricing and credit rules on the server before an order is accepted, across 146 PostgreSQL tables and 530+ tests. See the Sundor Skin case study.
  • Concurrency is tested. Usage limits and redemptions get race tests against a real database, gated in CI, not just happy-path unit tests.
  • Server-side pricing. On TheSkinProof, the founder's own venture built and run by RAITHub, prices and discounts are recomputed on the server inside row-locked transactions before payment.

When you don't need us

  • Platform discounts fit. If Shopify or WooCommerce promotions cover your rules, configure them.
  • Promotions are rare and simple. One standing discount does not need an engine.
  • You only need the evaluator. The model above is a fair start for your own developer.

How RAITHub would build this

  • Rule model: conditions, discount, scope, limits and stacking groups as data, with a typed evaluator.
  • Stacking and exclusions: exclusive by default, named stacking groups, product and category exclusions, a fixed order of operations.
  • Atomic limits: total and per-customer usage enforced in the order transaction, with redemption reversal on cancellation and return.
  • Tests: unit tests for the rules and Playwright race tests for abuse, gated in CI.

Timeline: adding an engine to an existing store is backend work, typically 6 to 12 weeks; inside a new store MVP it fits the 4 to 6 week fixed-scope range. See API and backend development, SaaS development and the ecommerce industry page.

You receive: automated tests and CI including concurrency tests for usage limits, handover docs, and full IP in your name under NDA.

Next step: book the free 15-minute technical audit with the promotions you run and the abuse you have seen, and we will follow up with a written fixed quote.

Frequently asked questions

What is a promotions engine?

A component that evaluates discount rules against a cart: conditions, discount, scope, limits and stacking. It applies the strongest valid set under your rules, the same way every time, instead of a growing pile of if-statements.

Should coupons stack?

Default to exclusive, so at most one order-level discount applies and the shopper gets the lowest valid price. Allow stacking only between promotions you explicitly tag as compatible, such as a product discount plus free shipping.

Why does my single-use coupon get redeemed twice?

Because checking the limit and recording the redemption are two steps, and two orders slip between them. Make the check and the increment one atomic database update inside the order transaction, so only one order can win.

How do I test for coupon abuse?

Unit-test the rules (stacking, exclusions, expiry, minimums), then fire concurrent redemptions of a single-use code in an end-to-end test and assert exactly one succeeds. Also test that removing the qualifying item re-evaluates the discount.

Where should the discount be calculated?

On the server, at checkout, from the cart and the catalogue. Never trust a discount the browser computed. Recompute the final price before charging, so a tampered request cannot change what the shopper pays.

Can I use my platform coupons instead of building an engine?

Often yes. Shopify and WooCommerce cover standard discounts. Build custom when stacking is complex, conditions are bespoke, or codes tie into your own store credit and loyalty system.

coupon enginepromotions enginediscount rulescoupon abuseusage limitsecommerce testing

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.