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:

  1. 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.
  2. Within the same scope, higher priority, then oldest rule.
  3. 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.