Discount module
Module id: discount. Dependencies: cart (and, through cart,
transitively catalog + customer — but cart is the only direct
requirement).
What it adds:
- Promo codes a shopper applies to the cart and that are frozen onto the order at checkout.
- A sidebar Discounts tab: create / edit / delete codes, toggle them active, set a validity window and a global redemption cap.
- Four discount types: percentage off, fixed amount off, free shipping, and buy-X-get-Y (BOGO) scoped to products or categories.
- A public apply/remove endpoint on the cart, and a live discount line in the cart/checkout totals.
- A tamper-proof redemption ledger: a code can never oversell its cap or be counted twice for the same order.
Discounts sit one layer above cart. A boutique that runs Cart but not Checkout can still preview a discount in the cart totals; the frozen snapshot only matters once Checkout places an order.
What's per-website
No settings entity. Unlike Checkout (checkout_settings) or
Customer (customer_settings), Discount has no per-website
configuration panel. Each code is itself the unit of configuration —
a Discount row scoped to a Website by its website_id FK. There is
nothing to configure under Module settings; everything is on the
Discounts tab where you create the codes.
A code's code string is unique per (website, code) and is stored
upper-cased and trimmed — summer24, SUMMER24 and Summer24 are
the same code.
Discount types
| Type | id | What it does |
|---|---|---|
| Percentage off | percent |
Takes a percentage (stored as basis points, 0–10000) off the net subtotal. |
| Fixed amount off | fixed |
Takes a fixed number of cents off, in the cart's currency, clamped to the subtotal. |
| Free shipping | free_shipping |
Waives the shipping line at checkout. The discount amount is 0; the saving is the shipping fee. |
| BOGO | bogo |
Buy-X-get-Y over the cheapest qualifying units, scoped to chosen products and/or categories. |
A discount has no currency of its own. Percentages are
currency-agnostic; a fixed amount is read in whatever currency the
cart is in. Don't create a €10 off code expecting it to become
$10 off on a dollar website — create a separate code per currency
if you run multiple.
BOGO specifics
A BOGO code carries three extra knobs:
- Buy quantity (the X) and get quantity (the Y) — e.g. buy 2, get 1.
- Reward is percent — when on, the "get" units are discounted by
the code's percentage (
50%→ half-price third item); when off, the "get" units are free. - Scope — a set of products and/or categories. A cart line
qualifies if it matches the scope (or if both scope lists are
empty, in which case every line qualifies). Scope is stored in the
discount_productanddiscount_categorypivot tables.
The reward is always applied to the cheapest qualifying units, so a "buy 2 get 1" on a mixed basket discounts the least expensive eligible item — the customer-friendly, store-unfriendly interpretation, matching how shoppers expect BOGO to work.
Creating a code — the fields
On Discounts → New:
| Field | Applies to | Notes |
|---|---|---|
| Code | all | Unique per website, stored upper-cased. The string the shopper types. |
| Type | all | One of the four above. Immutable after creation — to change a type, make a new code. |
| Value | percent, fixed, bogo% | Percentage (for percent / BOGO-percent) or cents (for fixed). |
| Active | all | Master on/off toggle, independent of the date window. |
| Starts at / Expires at | all | Optional validity window. Blank = no lower / upper bound. |
| Minimum order | all | Net subtotal (in cents) the cart must reach before the code applies. 0 = no minimum. |
| Maximum redemptions | all | Global cap across all shoppers. Blank = unlimited. |
| Buy / Get / Reward % | bogo | The BOGO knobs above. |
| Products / Categories | bogo | The BOGO scope. |
How a code is validated
When a shopper applies a code (or the cart is summarised with a code already held), the code is checked through these gates in order. The first one that fails is the reason the shopper sees:
| Gate | Rejection id | Meaning |
|---|---|---|
| Empty cart | empty_cart |
There are no items to discount. |
| Exists | not_found |
No such code on this website. |
| Active | inactive |
The admin toggle is off. |
| Not before start | not_yet_active |
The current time is before Starts at. |
| Not after end | expired |
The current time is after Expires at. |
| Minimum order | below_min_order |
The net subtotal is below Minimum order. |
| Redemptions left | redemptions_exhausted |
The global cap has been reached. |
| Applicable (BOGO) | not_applicable |
A BOGO code whose scope matched no cart line. |
Validation is read-only: summarising a cart never records a redemption or mutates the code. The same gates run again at order-placement; if a held code has gone invalid mid-session (expired, admin disabled it, cap filled), the order still places with no discount rather than blocking the shopper.
One case is different: when the code passed those gates but another order took its last use in the moment before this one was placed, the order is not placed. The shopper is sent back to the checkout with a message and sees the total without the discount before anything is charged, rather than being charged more than the page showed.
How redemption is counted
The redemption ledger is the careful part. A use of the code is claimed when the order is placed, not when the code is applied to the cart, and given back if that order is cancelled (payment failed or expired, the shopper started over, or an operator cancelled it). Counting only at payment let several shoppers place orders on the last use at once and keep the discount on all of them. The claim is made in the same transaction as the order, and it is both cap-safe and idempotent:
- The
discount.redemption_countcounter is bumped with an atomic conditional update that only succeeds while the cap allows — two near-simultaneous final units can't both win. - A
discount_redemptionaudit row records(discount, order, email, customer, amount, redeemed_at)and is guarded by a UNIQUE(discount, order) key, so a re-delivered webhook can't double-count. A cancelled order's row stays, withreleased_atset, and no longer counts.
That means the Maximum redemptions cap is a hard ceiling on orders that carry the code (paid ones plus those still awaiting payment), and it survives webhook retries, double clicks, and concurrent checkouts. A pending order holds its use until it is paid or cancelled.
How it lands on the order
The cart holds the discount as a foreign key (cart.discount_id),
so deleting a Discount row cleanly clears it from any cart still
holding it. At order placement the discount is frozen onto the
order as a snapshot that stays readable even if the code is later
edited or deleted:
| Order column | What it holds |
|---|---|
discount_cents |
The amount removed from the net subtotal (0 for free shipping). |
discount_code |
The upper-cased code string, snapshotted. |
discount_type |
The type (percent / fixed / free_shipping / bogo). |
discount_id |
FK back to the live row — for reporting/joins only, not authoritative. |
The three snapshot columns are the source of truth for receipts and accounting; the FK is kept only so reporting can join back to the live code.
Theme helpers (when discount is enabled)
The Discount module ships no Twig functions of its own and adds
nothing new to the sandbox allowlist. Themes read discount data through
the Cart module's cms_cart_view() summary, which gains three keys
when a valid code is held:
| Summary key | Type | Meaning |
|---|---|---|
discount |
int (cents) | The computed discount amount on the current cart. |
discountType |
string | null | The held code's type, or null when no code applies. |
freeShipping |
bool | true when a free_shipping code is waiving shipping. |
{# A theme's cart template, overriding @website/cart/_view.html.twig #}
{% set s = summary %}
<div>Subtotal: {{ s.subtotal|cms_money(s.currency) }}</div>
{% if s.discount > 0 %}
<div>Korting: −{{ s.discount|cms_money(s.currency) }}</div>
{% endif %}
{% if s.freeShipping %}
<div>Gratis verzending</div>
{% endif %}
<div>Totaal: {{ s.total|cms_money(s.currency) }}</div>
The apply/remove form is not shipped in a core template — a theme renders its own code input and posts it to the routes below. See ../../twig-helpers.md#cart-module-cart for the cart summary keys.
Public routes
| Route | Method | Path | Purpose |
|---|---|---|---|
cms_cart_discount_apply |
POST | /cart/discount |
Apply a code to the current cart (code in the body). |
cms_cart_discount_remove |
POST | /cart/discount/remove |
Clear the held code. |
Both use Post/Redirect/Get and flash the outcome into the cart's
cart_success / cart_error bags, which a theme surfaces via
cms_cart_flashes() / cms_cart_notice(). The endpoints 404 when the
module is off for the website.
The Discounts admin
Sidebar → Discounts. List of every code on the active website with its type, value, active state, validity window, and redemption count (used / cap). Click a row to edit; the toggle on the row flips a code active/inactive without opening it. BOGO codes show their buy/get/scope in the detail view.
There is no bulk import and no per-code usage report beyond the
used/cap counter — the full ledger lives in the discount_redemption
table if you need to audit who redeemed what.
Things this module does NOT do
- No per-customer / per-email cap. The redemption cap is global across all shoppers. "One use per customer" is not modelled — the ledger records the buyer, but nothing enforces uniqueness per buyer.
- No automatic / cartrule discounts. A code must be typed in. There are no "spend €50, save 10% automatically" rules.
- No stacking. A cart holds one code at a time; applying a second replaces the first.
- No free-gift / add-a-product reward. BOGO discounts the price of units already in the cart; it never adds a line item.
- No multi-currency code. A fixed-amount code is read in the cart's currency as-is — make one code per currency for multi-currency sites.