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_product and discount_category pivot 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_count counter 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_redemption audit 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, with released_at set, 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.