Summary: Behaviour notes, known limitations, and edge cases for product decision-making.
See it yourself: Two Usage-Limit Systems — a side-by-side walkthrough of the real admin screen showing the old flat-cap system versus the newer promotion discount-pool system.
A coupon is its own independent thing — it has its own rules for who can use
it and how many times, separate from any specific order. Coupons come from
two sources: created by hand by an admin, or created automatically when a
trade promotion is approved (this second source only exists on the ci
branch right now, not in production).
This can be controlled by customer account, customer group, product, product group, and — for the automatic promotion coupons — sales region, sales channel, and customer type. Each of these can be set as "only these" or "everyone except these," and those two modes aren't handled the same consistent way across the board. This is worth a closer look from a design standpoint, since the same restriction can currently be built in more than one different way, which invites mistakes.
There are two separate systems doing this today, and they don't talk to each other:
A single coupon uses only one of these two systems, never both.
Whether a coupon gets released back to the customer depends on a configurable list of which order statuses count as "cancelled." For the automatic promotion coupons specifically, there's a special rule: if the coupon expires or gets replaced while it's already attached to an order still in progress, that order keeps what it already had, but can't add more, and nothing already given can be taken away.
Coupons created automatically by a promotion cannot be edited or deleted through the normal admin screens at all — the system blocks it outright. Any change has to happen by fixing and re-approving the promotion itself. This is deliberate, so it's worth double-checking this matches what we actually want long-term, since it means even support staff can't hand-fix a broken promotion coupon directly.
This section is written for scoping decisions — so that any future enhancement to coupons (or a similar module) can be designed without breaking what customers already rely on. It covers the create/update payload contract, its constraints, and how the two usage-limit systems are actually enforced under the hood.
A coupon is one record with a fixed set of fields — there's no separate schema per coupon type. The same payload shape is used whether a coupon is hand-created by an admin or generated automatically by a trade promotion; what differs is which fields get populated and who's allowed to write to it afterward (see "Who can edit a coupon," above).
This is the actual payload shape accepted by the create and update endpoints (both take the same shape):
| Field | Type | Required | Constraint |
|---|---|---|---|
code |
string | Yes | Must be unique across all coupons — enforced at the database level. The customer-facing redemption code. |
name |
string | Yes | Display name. |
description |
string | No | Free text. |
type |
string | Yes | One of: cart_discount (whole-order), product_discount (specific products), product_discount_on_msrp (product discount calculated off MSRP, not current selling price). |
discount_type |
string | Yes | One of: percentage, unit_amount (flat amount). Never both, never a scale that varies by quantity. |
discount |
number | Yes | The percentage or flat amount. The amount actually applied is capped at checkout so a line can't go negative or free — that cap is computed at apply-time, not stored on the record. |
start_at, end_at |
date | No | Null on either side means "no boundary" on that side, not "inactive." |
max_quantity_limit |
number | Yes | Total-redemption cap across all customers. Only enforced for a standard (non-promotion) coupon — see below. |
max_redemption_limit |
number | Yes | Per-customer redemption cap. Only enforced for a standard coupon. |
max_amount_limit |
number | No | Accepted and validated, but not currently enforced anywhere in usage/redemption logic — flag this before any roadmap item assumes it works. |
min_document_amount |
number | No | Same gap as above — accepted, validated, not enforced. |
point_needed |
number | Yes | Loyalty-points cost to redeem. Independent of the quantity/redemption caps — a separate gate. |
is_redeemable |
boolean | No | Whether the coupon appears in the points-redemption catalog at all. |
is_redemption_required |
boolean | No | Whether a customer must explicitly redeem (spend points, create a usage record) before the coupon can be applied to an order, vs. being auto-available at checkout. |
is_accounts_excluded |
boolean | No | Flips the linked customer-account list from "only these" to "everyone except these." Default (omitted) behaves as "only these." |
is_tiers_excluded |
boolean | No | Same "only/except" flip, for the linked customer-tier list. |
is_products_excluded |
boolean | No | Same "only/except" flip, for the linked product list. |
is_product_groups_excluded |
boolean | No | Same "only/except" flip, for the linked product-group list. |
are_bundles_excluded |
boolean | No | Not an eligibility scope — a separate yes/no on whether composite/bundle line items can be discounted at all. |
image_id |
number | No | Must reference an existing media record if present. |
tiers_ids, companies_ids, products_ids, product_groups_ids |
array of IDs | No | The actual eligibility lists that the four is_*_excluded flags above apply to. Sent as separate arrays alongside the coupon fields, not nested inside them. |
Three additional eligibility dimensions — sales region, sales channel, and customer classification — exist only for automatic promotion coupons, as their own ID-list fields alongside the ones above. A customer must pass every populated dimension (it's an AND across dimensions), and tier membership for a promotion coupon is evaluated against the customer's live record at usage time, not frozen at coupon-creation time.
Note: "tier" here means a customer/pricing tier used for eligibility matching — a completely different concept from tiered discount pricing (different % at different quantities), which this system does not support at all (see Known limitations).
What's deliberately absent from this payload: no field for a tiered/scaled discount curve, no per-order multi-attach flag (a coupon-to-order relationship is always one-to-one), and no independent "usage pool" field for standard coupons — the pool mechanism below only exists for promotion-generated coupons and isn't part of this payload at all; it's computed and stored separately once a coupon is linked to a promotion.
The "two systems" aren't two settings on the same coupon — they're two different enforcement code paths, selected automatically by whether the coupon has a linked trade promotion:
The immutability of promotion-generated coupons isn't just a UI restriction — direct create/update/delete calls for a coupon are rejected outright (an access-denied response) if that coupon has a linked promotion, both for single-record and bulk operations. There is currently no override path, including for support/admin roles, other than fixing and re-approving the source promotion. Any future "let support hand-fix one field" request would need a deliberate exception carved into this check, not just a frontend permission change.
Which order statuses count as "cancelled" for the purpose of releasing a coupon is a single stored setting (one shared list of statuses), not hardcoded per coupon or per coupon type — so this is a legitimate place for product/ops to tune behavior without an engineering change. What is not configurable is the grandfathering rule for promotion coupons described above — that logic runs regardless of what the cancellation-status setting says, because it triggers on the coupon/promotion becoming inactive, not on order cancellation specifically.
ci branch
today, not in production — any roadmap messaging should reflect that it
hasn't shipped yet.