Catalog & volume discounts module
Module id: catalog_discount. Dependencies: catalog.
Automatic price rules. The customer types nothing: a product is simply cheaper, in the catalogue, in the cart, and on the order.
Two kinds:
- Sale: a percentage or fixed reduction on a product or a whole category, optionally over a start/end window.
- Volume: quantity-tiered unit pricing. Buy 10 and every one of them costs less.
Not the same thing as Vouchers
The two discount modules sit next to each other in the sidebar and do different jobs:
Catalog discounts (catalog_discount) |
Vouchers (discount) |
|
|---|---|---|
| Trigger | Automatic, no code | Customer enters a code |
| Applies to | The unit price of a product or category | The cart total |
| Visible where | Catalogue, product page, cart, order | Cart and checkout only |
| Depends on | catalog |
cart |
They stack. A catalog rule lowers the line price first, then a voucher applies to the reduced subtotal. That is worth knowing before you run both at once: a 20% sale plus a 10% voucher is not 30% off, it is 0.8 × 0.9 = 28% off, and the voucher's own cap applies to the already reduced amount.
See discount.md for the voucher side.
What a rule holds
| Field | What it does |
|---|---|
| Name | Admin-facing label. Never shown to the customer. |
| Kind | Sale or Volume. |
| Scope | One product, or one category. |
| Adjustment | Sale only: percent, fixed amount off, or an absolute new price. |
| Value | Basis points for percent (2000 = 20%), cents otherwise. |
| Tiers | Volume only: a list of (minimum quantity → adjustment). |
| Active | Off parks the rule without deleting it. |
| Starts / expires | Optional window. Empty on either side means open-ended. |
| Priority | Tie-break between rules of the same scope. Higher wins. |
A rule targets a product or a category, never a single variant. Variants inherit their product's rules, priced off the variant's own base price.
Which rule wins
Only one catalog rule ever applies to a line. They do not compound.
The order of resolution:
- A product rule beats a category rule. More specific wins, so putting one product on a deeper discount than its category is just a second rule, not a fight with the first.
- Within the same scope, higher priority, then oldest rule.
- The first rule in that order that actually produces a saving at this quantity wins.
That last clause matters more than it looks. A volume rule whose lowest tier starts at 10 contributes nothing when the customer buys 3, so it is skipped and a lower-priority sale rule still applies. You can therefore run "10% off this category" and "buy 10+ for €4 each" side by side and each takes over where it is the better deal, with no scheduling around each other.
A rule that would price a unit above its base price never applies. Prices never go below zero either.
Volume tiers price the whole quantity
The highest tier the quantity reaches sets the price for every unit, not just the ones past the break.
Tier 1+ €5.00
Tier 10+ €4.50
Tier 50+ €4.00
Buy 12 and the whole order line is 12 × €4.50, not 9 × €5.00 + 3 × €4.50.
Because tiers depend on quantity, the price is re-resolved when the shopper changes the quantity in the cart. Someone who adds 9 and then bumps to 10 sees the whole line drop.
Where the price is recalculated
Everything that charges money goes through one resolver, so no payment path misses a rule:
- the cart, at add time and on every quantity change;
- checkout, re-resolved at the moment the order is frozen.
Since cms-core 0.4.34 the displayed price is one of them too.
cms_price() resolves through the same resolver, and the bundled catalog
templates (product page, category and tag listings, search results) show
the crossed-out old price and the percentage saved.
Before 0.4.34 the catalogue kept showing the old price while the cart charged the new one. Nobody was overcharged, but the discount did no selling and the mismatch read as a bug. A site still on an older cms-core has to say the sale price in the product text or a banner instead.
The last one is the important one for operators: the order captures the price in effect when it was placed. Ending a sale does not reprice orders already taken, and a customer who sat on a cart across the end of a sale is charged the price at checkout, not the one they first saw.
With the module off, the resolver returns the base price and everything behaves exactly as a shop that never had rules. Turning it off is a safe way to end all automatic pricing at once.
What a theme has to do
Nothing. Prices in the cart and on the order have always been resolved,
and since cms-core 0.4.34 cms_price() and the bundled templates show the
discounted price too.
A theme that wants to control the was/now markup itself, rather than take the bundled partial, asks for both figures:
{% set ep = cms_effective_price(p) %}
{% if ep.on_sale %}
<span class="was">{{ ep.base_gross|cms_money(ep.currency) }}</span>
<span class="now">{{ ep.price_gross|cms_money(ep.currency) }}</span>
<span class="badge">-{{ (ep.saving_bp / 100)|round }}%</span>
{% else %}
{{ ep.price_gross|cms_money(ep.currency) }}
{% endif %}
Pass a quantity to preview a volume break ("10 or more: €4.50 each"):
{% set ten = cms_effective_price(p, 10) %}
on_sale is false and base == price when the module is off, so a
shared theme can print price_gross unconditionally and never branch.
See twig-helpers.md.
The bundled markup lives in one partial, so a theme that only wants to
restyle it overrides @website/catalog/_price.html.twig instead of
rewriting each listing template.
No per-website settings
Everything lives on the rules themselves. The Module settings tab has no Catalog discounts panel.