Unit-Level Promotion Exclusions: Updated Behaviour and Developer Guide
Introduction
Since Commerce 13, Optimizely Commerce Connect has supported two modes for promotion exclusion: order-level and unit-level. Order-level exclusion is the default and the one most sites use. Unit-level exclusion is the more granular option, designed for scenarios where merchandisers want competing promotions to coexist on the same order -- just not on the same product unit.
While the concept has been available for years, several edge cases in unit-level exclusion behaved inconsistently. Depending on how exclusions were declared, whether condition units were counted, and what type of promotion was involved, the results could be surprising and difficult to reason about. In Commerce 14.47.0, we corrected these inconsistencies to make unit-level exclusion behave predictably and symmetrically. The same corrections are planned for an upcoming Commerce 15 release.
This post explains the unit-level exclusion model from scratch, details the five behaviour changes introduced in 14.47.0, and provides a migration guide for developers upgrading their sites.
Does this affect you? Only if your site sets PromotionExclusionLevel = ExclusionLevel.Unit, either globally or per invocation. If you use the default ExclusionLevel.Order, nothing changes.
The Business Problem
Consider a common merchandising scenario. A retailer runs two promotions:
- P1: 15% off all Wella brand products (narrow, brand-specific)
- P2: 10% off the entire Shampoo category (broad, category-wide)
Both promotions target shampoo, but the merchandiser marks them as mutually exclusive so that a Wella shampoo does not receive both discounts (25% off would erode the margin too much). Now a shopper adds two items to the cart: one Wella shampoo at 200 and one non-Wella shampoo at 100.
With order-level exclusion (the default), the engine evaluates P1 first (higher priority). P1 applies and the engine marks P2 as excluded from the entire order. Result: the Wella shampoo gets 15% off, but the non-Wella shampoo gets nothing -- even though P1 never touched it.
With unit-level exclusion, the blast radius is narrowed. P1 consumes only the Wella shampoo unit. P2 is excluded from that unit, but the non-Wella shampoo is still free for P2 to discount.
| Item | Unit price | Order-level result | Unit-level result |
|---|---|---|---|
| Wella Shampoo | 200 | 170 (P1, -15%) | 170 (P1, -15%) |
| Other Shampoo | 100 | 100 (no discount) | 90 (P2, -10%) |
| Total | 270 | 260 |
The shopper saves more, the merchandiser's margin on the Wella product is preserved, and the non-Wella shampoo is not unfairly penalised. This is the core value proposition of unit-level exclusion: exclusions should only affect the units a promotion actually used, not everything else in the cart.
How Unit-Level Exclusion Works
Vocabulary
| Term | Definition |
|---|---|
| Unit | The atomic object of exclusion accounting. Not a line item -- one line item with quantity 5 contains five units. A cart with one line item of quantity 5 must behave identically to five separate line items of quantity 1. |
| Condition unit | A unit consumed to satisfy a promotion's condition. In "buy 3, get 10% off one", the three units that satisfy the "buy 3" requirement are condition units. They are charged full price but consumed. |
| Reward unit | A unit that receives the discount or is given free. |
| Consumed | A unit used as either a condition unit or a reward unit by a given promotion. Counted once per unit, not per role -- if a unit is both condition and reward (as in a simple "10% off all items"), it counts once. |
| Free unit | A unit not yet consumed by any promotion that is in an exclusion relationship with the current one. This is the pool available for the current promotion. |
| Redemption | One application of a promotion. If a promotion is applied n times (up to its redemption limit), it produces n redemptions, each consuming its own batch of units. |
Evaluation Algorithm
The promotion engine processes exclusions with the following steps:
- Order promotions by priority, highest first.
- For each promotion, determine its free units: every unit in the cart that has not been consumed by a promotion in an exclusion relationship with the current one. Units consumed by non-excluding promotions are irrelevant -- they do not reduce the pool.
- Test the condition against free units only. If the promotion requires "buy 3" and only 2 free units remain, the condition is not met.
- If the condition is met, redeem. Record both condition units and reward units as consumed by this promotion.
- If the condition cannot be met from free units, the promotion does not apply. There is no partial redemption -- the entire condition must be satisfiable.
- Continue with the next promotion in priority order.
Configuration
There are two ways to enable unit-level exclusion.
Global setting -- applies to all ApplyDiscounts calls:
services.Configure<PromotionOptions>(options =>
{
options.PromotionExclusionLevel = ExclusionLevel.Unit;
});
Per invocation -- overrides the global setting for a single call:
var settings = new PromotionEngineSettings
{
ExclusionLevel = ExclusionLevel.Unit
};
cart.ApplyDiscounts(_promotionEngine, settings);
Declaring Exclusions
Exclusions are configured on the PromotionData.ExcludedItems property, which accepts a list of ContentReference values. Each reference can point to:
- A specific promotion (
otherPromotion.ContentLink) -- excludes that one promotion - A campaign (
campaign.ContentLink) -- excludes all promotions within that campaign - The campaign root (
SalesCampaignFolder.CampaignRoot) -- excludes all other promotions site-wide
Merchandisers configure these through the Commerce UI under Discount Priorities and Exclusions, without writing code.
What Changed in Commerce 14.47.0
Does This Affect Me?
You are affected if either of the following is true:
- You set
PromotionOptions.PromotionExclusionLevel = ExclusionLevel.Unitglobally in your service configuration - You pass
ExclusionLevel = ExclusionLevel.Unitfor individual calls throughPromotionEngineSettings
If you use the default (ExclusionLevel.Order), nothing changes. Stop reading here and carry on.
Summary of Changes
| Change | Previous behaviour | New behaviour | Likely price effect |
|---|---|---|---|
| One-way exclusions now work like two-way exclusions | The result depended on which promotion listed the exclusion. Sometimes a promotion was dropped completely. | It does not matter which promotion lists the exclusion. The lower-priority promotion applies to any units the higher-priority one did not use. | Bigger discounts |
| Product promotions that conflict with order or shipping promotions | Depending on setup, shoppers could sometimes combine promotions meant to exclude each other. | The product promotion is now excluded in full, whichever way the exclusion was set up. | Smaller discounts |
| Units used by a promotion | A promotion only used up the units it discounted. | A promotion now uses up all units that met its condition. "Buy 3, get 10% off one" on quantity 10 now leaves 7 units for other promotions, not 9. | Smaller discounts |
| Buy X get free items, cheapest free, most expensive free | These promotions could use units that other promotions had already used. | These promotions now only get units that are left over, like other item discounts. | Fewer free items |
| Count units as consumed | Only count reward units as consumed. | Count both condition and reward units as consumed. | Smaller discounts |
| Consistent reporting of excluded promotions | BuyOneGetOneDiscount returned Excluded with no redemption. Other types were inconsistent. |
Every product-level promotion that ends up with no units returns Fulfilled with one redemption whose status is FulfillmentStatus.Excluded. |
No price change, but code that lists applied promotions may need updating |
Detailed Breakdown
One-way vs two-way exclusions
Previously, declaring "P1 excludes P2" versus "P2 excludes P1" versus both could yield different results. The engine checked for "double exclusion" (both sides listing each other) as a special case, and the one-way path had different logic from the two-way path.
Now all three configurations produce identical outcomes. The exclusion relationship is symmetric -- it means "these two promotions must not stack on the same unit." The higher-priority promotion gets first pick of units; the lower-priority promotion works with whatever is left. The IsDoubleExclusion method on OrderFormPriceMatrix is now obsolete because the engine no longer distinguishes between one-way and two-way exclusions.
The practical effect is that promotions which were previously dropped entirely (because they were on the "wrong" side of a one-way exclusion) now apply to leftover units. This tends to increase discounts.
Condition unit consumption
This is likely the most impactful change. Consider:
- P1 (higher priority): Buy 3 of SKU A, get 10% off one. Redemption limit: 1.
- P2: 5% off SKU A.
- P1 and P2 exclude each other.
- Cart: SKU A x 10
Previously, P1 consumed only 1 unit (the one that received the 10% discount). The 3 units that satisfied the "buy 3" condition were not tracked. P2 saw 9 free units.
Now, P1 consumes 3 units: the 1 reward unit plus the 2 condition-only units. P2 sees 7 free units.
| Units | Role | Unit price | Line total |
|---|---|---|---|
| 1 | P1 reward (-10%) | 90 | 90 |
| 2 | P1 condition (full price) | 100 | 200 |
| 7 | Free, P2 applies (-5%) | 95 | 665 |
| 10 | 955 |
This change means promotions with large condition quantities relative to their reward quantities (common in "buy X get Y" structures) now consume more units, leaving fewer for competing promotions. The effect is generally smaller discounts.
Order/shipping promotions remain fully exclusive
Unit-level exclusion only applies to product-level (DiscountType.LineItem) promotions. When one side of an exclusion relationship is an order-level or shipping-level promotion, the engine cannot meaningfully split units -- these promotion types operate on the order as a whole. In this case, the product promotion is excluded in full, exactly as it would be under order-level exclusion.
Previously, depending on how the exclusion was declared, the two promotions could sometimes both apply. This is no longer possible.
Developer Migration Guide
IsDoubleExclusion Is Obsolete
If your code calls OrderFormPriceMatrix.IsDoubleExclusion, replace it with OrderFormPriceMatrix.IsExcluded. The IsExcluded method handles one-way and two-way exclusions identically, which matches the new engine behaviour. IsDoubleExclusion still works but will be removed in a future major version.
Custom Processors Based on GetFreeItemsProcessorBase
The GetAffectedEntries method now only returns product codes that still have free units available. If your custom free-item processor overrides this method, be aware that:
- Your processor may receive fewer batches than before, because units consumed as conditions by higher-priority promotions are no longer available.
- Review and test any custom logic that assumes all cart units are available.
Filtering Applied Promotions
Code that lists applied promotions -- for cart messages, analytics, order confirmation, or order notes -- must now account for excluded redemptions. A promotion whose units were all consumed by excluding promotions is still returned with status Fulfilled (its condition was met by the cart contents), but its single redemption carries status FulfillmentStatus.Excluded. It saves nothing, adds no gift items, and should not count as "applied" in the UI.
Filter these out:
var applied = rewardDescriptions.Where(r =>
r.Status.HasFlag(FulfillmentStatus.Fulfilled) &&
r.Redemptions.Any(x => x.Status != FulfillmentStatus.Excluded));
Without this filter, you may display promotions to the shopper that saved nothing, which is confusing.
RewardApplicatorBase Changes
The Apply method in RewardApplicatorBase<T> no longer marks an excluded gift redemption as fulfilled. If you have custom reward applicators that extend this base class, review this change and ensure your override does not rely on the old behaviour.
Worked Examples
Example A: Promotion That Cannot Be Satisfied from Free Units
Setup:
- P1 (higher priority): Buy 2 of SKU A, get the cheapest free
- P2: Buy 3 of SKU A, get them for fixed 20 each
- P1 and P2 exclude each other
- Cart: SKU A x 3 @ 100 each
Result: P1 consumes 2 units (1 condition + 1 reward). Only 1 free unit remains -- fewer than P2's requirement of 3. P2 does not apply.
| Scenario | P1 units | Free units | P2 applies? | Total |
|---|---|---|---|---|
| Cart quantity 3 | 2 | 1 | No | 200 |
| Cart quantity 4 | 2 | 2 | No | 300 |
| Cart quantity 5 | 2 | 3 | Yes (3 x 20) | 260 |
The key takeaway: P2 needs 3 free units. Adding a 4th unit to the cart still only gives 2 free (P1 has already consumed 2). Only when the 5th unit is added does P2 have enough.
Example B: Partial Overlap Across SKUs
Setup:
- P1 (higher priority): Buy any 3 across SKUs A, B, C, get 10% off
- P2: Buy any 2 across SKUs C, D, E, get 5% off
- P1 and P2 exclude each other
- Cart: A x 1, B x 1, C x 2, D x 1, E x 1 (all at 100)
Result: P1 consumes A x 1, B x 1, C x 1 (3 units). Only the overlapping SKU (C) is reduced for P2's pool. D and E were never touched by P1.
P2's free pool: C x 1, D x 1, E x 1 = 3 free units. P2's condition of 2 is met. P2 applies.
| SKU | Qty | Consumed by P1 | Free for P2 |
|---|---|---|---|
| A | 1 | 1 | 0 (not in P2's target) |
| B | 1 | 1 | 0 (not in P2's target) |
| C | 2 | 1 | 1 |
| D | 1 | 0 | 1 |
| E | 1 | 0 | 1 |
The critical detail: only overlapping SKUs have their free count reduced. Subtracting P1's total consumed count (3) from every SKU in P2's pool would wrongly starve D and E, which P1 never touched. The engine tracks consumption per SKU, not as a global counter.
Example C: Order-Level Promotion Falls Back to Full Exclusion
Setup:
- P1 (higher priority): 50 off the order (
DiscountType.Order) - P2: 20% off Men's Shoes (
DiscountType.LineItem) - P1 and P2 exclude each other
Result: P1 is not a line-item promotion, so there are no individual units to split. P2 is excluded in full. Unit-level exclusion never weakens an exclusion involving order-level or shipping-level promotions.
This is by design. Order and shipping promotions operate on the order as a whole. There is no meaningful way to determine which specific product units an order-level discount "consumed," so the engine falls back to full exclusion for safety.
Example D: Non-Excluding Promotion Between Two Excluding Ones
Setup:
- P1 (highest priority): Buy 2 of Men's, get the most expensive free
- P2 (medium priority): Buy 2 of Men's, get 50% off -- no exclusions declared
- P3 (lowest priority): Spend 10 or more, get a gift item free
- P1 and P3 exclude each other. P2 has no exclusion relationships with anyone.
- Cart: Men's item x 2 @ 33.50
Result:
- P1 consumes 2 units (1 condition + 1 reward). The most expensive unit is free.
- P2 has no exclusion relationships, so it sees all units as available. P2 stacks on top of P1's result.
- P3 checks its free pool: both units were consumed by P1 (which excludes P3). Effective value from free units is 0, which is below the 10 threshold. P3 does not apply.
The key insight: a non-excluding promotion (P2) cannot release units that an excluding promotion (P1) consumed. Exclusion tracking is per unit against the original claiming promotion. P2 freely stacks because it has no exclusion relationships, but P3's view of the cart is restricted to units P1 did not consume.
Recommendations and Known Limitations
Migration Checklist
- Check your setup -- confirm whether your site uses
ExclusionLevel.Unit. Search your codebase forExclusionLevel.Unitand check your service configuration forPromotionOptions. - Review your active promotions -- focus on mutual exclusions, product vs order/shipping mixes, and buy-X or free-item promotions. These are the areas where behaviour has changed.
- Test before you go live -- compare order totals on a test environment before and after the upgrade, using carts that match your real promotion mix. Automated tests with known expected totals are ideal.
- Update your code -- replace
IsDoubleExclusionwithIsExcluded, review custom free-item processors, and update any promotion-listing code to filter excluded redemptions.
When to Use Which Level
- Order level (default): when exclusion means "these offers must never both appear on this order." Example: a loyalty programme offer that is void when a clearance sale is active.
- Unit level: when exclusion means "do not stack these on the same product unit." Example: a category-wide promotion alongside a brand-specific promotion where you want both to apply, just not to the same item.
Known Limitation
When one promotion covers several products and only some of those products overlap with an excluding promotion, the discount can still land on units already used by the other promotion in certain edge cases involving multi-product reward distributions. Single-SKU pools and non-overlapping pools are not affected by this limitation.
A Note on Switching Levels
Switching from order-level to unit-level exclusion generally increases the total discount on an order, because promotions that were previously blocked entirely can now apply to leftover units. Model the margin impact before changing this setting in production.
Further Reading
- Optimizely developer documentation: Promotion exclusions
- The full technical specification for the unit-level exclusion model is available for Optimizely partners and employees
Comments