Add a cart contents condition to promotions and vouchers ("apply only when the cart contains X")
Problem
An action cannot depend on what the cart contains. A promotion or a voucher can define what it applies to and under which price conditions, but not that it should apply only when some other specific item is in the cart.
Cases merchants cannot model:
- product B costs 1 $ when product A is in the cart — bundle / cross-sell pricing
- 10 % off the Kitchen category when a stove is in the cart — a category discount unlocked by a different product
- a machine in the cart makes one battery cost 1 $ — the discount must not scale beyond what the cart actually contains
- free shipping when the cart contains a bike and the order is over 1000 $ — a cart condition composed with the existing price condition
- a voucher for the Kitchen category that is valid only when a stove is in the cart
Why this cannot be expressed with what exists today
| Mechanism | What it can do | Why it does not cover the use case |
|---|---|---|
orderPredicate |
price conditions only — baseSubtotalPrice, baseTotalPrice |
has no access to the contents of the cart |
cataloguePredicate |
product / variant / category / collection | defines what is discounted, not what must be in the cart |
| Voucher conditions | minCheckoutItemsQuantity, minSpent, once per customer, staff only |
nothing about which items must be present |
| Voucher targeting | products, variants, collections, categories |
restricts what a voucher discounts, not what the cart must contain; and the code has to be attached explicitly — vouchers never apply on their own |
GIFT reward |
free line | gift lines are always priced at 0, so "for 1 $" cannot be expressed, and a gift is a reward, not a condition |
| Catalogue rules | discount applied to the targeted variants | decided from the denormalized variant↔rule relation with no container context, and it changes the catalogue price |
| Apps / synchronous webhooks | checkout_calculate_taxes, order_calculate_taxes, checkout_filter_shipping_methods |
there is no hook where an app could return a line discount; the price-affecting hooks are tax and shipping only. Manual discounts exist for draft orders only (orderDiscountAdd, no checkoutDiscountAdd), and a voucher attached by an app out of band can be dropped by the next recalculation. |
Today this is worked around outside Saleor: an app reacting to CHECKOUT_UPDATED and attaching or removing a voucher. That is fragile — it competes with the checkout recalculation, it collides with a promo code the customer already entered (only one voucher per checkout), and the condition is not enforced by Saleor itself. As the table above shows, there is no hook where an app could return a line discount instead, so the workaround is the only option today rather than a design choice. The logic belongs in the core.
Related: #13331 scoped discountedLineObjectPredicate (quantity + catalogue predicate on checkout and
order lines) but was postponed. #12911 asked for a reward scoped to specific products. This issue is
about the condition itself, independent of the reward.
Discord Discussion Link
https://discord.com/channels/864066819866624010/1548701794459189369
General Assumptions
The condition is a gate: it decides whether an action applies. It is independent of what the action applies to and of the reward it gives.
- Available to promotions and vouchers. Both are evaluated when a container is priced.
- Available to every reward type: percentage discount, fixed amount discount, fixed price, discount on the order subtotal, free shipping, and gift.
- The condition and the targeting are separate. The condition may reference something else than the discount does — "a voucher for the Kitchen category, valid only when a stove is in the cart".
- Expressed in catalogue terms — product, variant, category, collection, and combinations, not only one hard-coded product id.
- Quantified. Either "at least one unit is enough" (a plain yes/no gate), or "each unit in the cart unlocks a discounted unit", which prevents "one machine = one battery" from becoming "one machine = all batteries". The second is only meaningful where there are units to distribute; for an order subtotal, shipping or a gift, the condition is a plain yes/no.
- Composable with the existing price condition — "free shipping when the cart contains a bike and the order is over 1000 $".
- Evaluated only when a container is priced (checkout and draft order). Adding or removing an item flips the condition, and the discount appears or disappears on the next recalculation with no manual step.
- It never affects the catalogue price.
ProductVariant.pricing, the channel listing price andonSalestay untouched — there is no cart in the catalogue, so a conditional action is only visible in the cart, the checkout and the order. The storefront has to communicate it.
Acceptance Criteria
Functional use cases:
- An action carrying a cart condition is not applied when the condition is unmet, is applied once it is met, and is removed when the container stops satisfying it — for a checkout and for a draft order.
- The condition can require a product, a variant, a category and a collection, and can combine them.
- Condition and targeting are independent in both directions: the condition may reference something the discount does not apply to, and the discount may target a different set than the condition references.
- The reward stays limited to the targeted items — for a category-targeted discount, items of other categories in the cart are not discounted.
- Works with a condition attached for every reward: percentage discount, fixed amount discount, fixed price, order subtotal discount, free shipping, gift, and a voucher.
- With the "each required unit unlocks a discounted unit" variant, the number of discounted units follows the units present and is not exceeded by adding more of the discounted item.
- A voucher whose condition is unmet does not apply at checkout and stops applying when the required item is removed.
- A condition combined with the existing price condition applies only when both are met.
- Validation: unresolved ids, unsupported combinations and list limits are rejected with a
field-level error;
MANAGE_DISCOUNTSas today. - Version compatibility: existing promotions and vouchers without a condition behave unchanged.
- Taxes are calculated from the discounted line prices on both checkout and order.
Non-functional use cases:
- Pricing a container adds no queries per line and no queries per rule; the conditions of all applicable rules are resolved together.
- The catalogue price calculation is not affected: no conditional rule takes part in it, and changing a condition can never leave a stale catalogue price behind.
- Explicit validation and authorization tests for the new API surface, with version annotations.
API Changes
The condition belongs next to the price condition that orderPredicate already supports, so it is
"another condition" rather than a new concept, and it inherits catalogue queries (category,
collection, product, variant) — which is exactly the shape needed: "at least one item matching this
catalogue query".
input OrderPredicateInput @doc(category: "Discounts") {
discountedObjectPredicate: DiscountedObjectWhereInput # existing, price based
discountedLineObjectPredicate: DiscountedLineObjectPredicateInput
AND: [OrderPredicateInput!]
OR: [OrderPredicateInput!]
}
input DiscountedLineObjectPredicateInput @doc(category: "Discounts") {
quantity: IntFilterInput
cataloguePredicate: CataloguePredicateInput
AND: [DiscountedLineObjectPredicateInput!]
OR: [DiscountedLineObjectPredicateInput!]
}To decide with you:
- whether an order-level rule may carry the cart condition without a price condition (today an
order rule is defined by
orderPredicate), - how the same condition attaches to vouchers, which are still modelled separately (voucher types,
products/variants/collections/categories, codes) rather than as promotion rules, - whether the "unlocked units" variant belongs in the first iteration.
Database Changes
No new tables are required for the predicate route: conditions live in the existing JSON predicate fields, and the work is evaluation and validation.
A data migration is needed regardless of the shape: listings and variants whose price was computed with a rule that now carries a cart condition must be marked dirty and recalculated, so a conditional rule can never leave a stale catalogue price behind.
If the condition is instead modelled as a relation on the rule (rather than in the predicate), the two many-to-many relations need indexes for the reverse lookup, because they are read while pricing.
UML Diagram
flowchart TD
A["checkout or draft order is priced"] --> B["load lines and the applicable rules"]
B --> C{"do the rule conditions match the container?"}
C -- "no" --> D["the action does not apply"]
C -- "yes" --> E{"which reward?"}
E -- "discount on products" --> F["discount only the targeted lines"]
E -- "order subtotal" --> G["discount the order subtotal"]
E -- "shipping" --> H["free shipping"]
E -- "gift" --> I["add the gift line"]
F --> J["how many units the container unlocks"]
J --> K["totals, taxes, shipping"]
G --> K
H --> K
I --> KTo Do List
- Add the condition to the predicate input and to the rule and voucher types, with version annotations.
- Evaluate the condition for every rule and voucher when a checkout or draft order is priced.
- Resolve the conditions of all applicable rules together — no query per line, no query per rule.
- Implement both variants: "at least one unit is enough" and "each required unit unlocks a discounted unit".
- Compose the condition with the existing price condition.
- Keep conditional rules out of the catalogue price calculation and recalculate the listings affected by the change (data migration).
- Allow an order-level rule to carry the cart condition without a price condition.
- Attach the condition to vouchers.
- Validation, permissions and error codes.
- Regenerate the schema, changelog entry, documentation.
- Dashboard support in a separate PR.
Testing Requirements
- Condition met / unmet on the same cart: assert the exact discount amount and the number of discount objects, not only the absence of errors.
- Adding and removing the required line: the discount appears, then is removed and totals are recalculated — checkout and draft order.
- Condition on a product, a variant, a category, a collection, and combinations.
- Condition and targeting independent: a voucher targeting one category with a condition on a product of another category — assert only the targeted category is discounted.
- Every reward type with a condition attached: percentage discount, fixed amount discount, fixed price, order subtotal discount, free shipping, gift.
- Vouchers: the code applies only when the condition is met and stops applying when the required item
is removed; interaction with
applyOncePerOrderand with product/category targeting. - Unlocked units: one required unit unlocks exactly one discounted unit; several affected lines share the unlocked units; adding more of the discounted item does not increase the discount.
- Composition with the existing price condition (both met, only one met).
- Catalogue price untouched:
ProductVariant.pricing, the channel listing price,onSale, minimal variant price. - Data migration: affected listings are marked dirty and recalculated exactly once.
- Taxes: tax app and flat rates,
prices_entered_with_taxboth true and false, discount distributed per line. - A line with
price_override, and a gift line present in the cart. - Query counts: pricing a cart with N lines stays within a fixed number of queries regardless of how many rules carry a condition.
- Deleting a required product, variant, category or collection does not leave a condition that can never be met.
- GraphQL: create/update, clearing the condition, list limits, unresolved ids.
- Authorization: unauthenticated, unprivileged, staff without
MANAGE_DISCOUNTS, staff with it — asserting the object was not modified in the denied cases.
Source: saleor/saleor