Advanced license required
Features described on this page require the Xperience by Kentico Advanced license tier.
The order discount framework allows you to create custom promotion rules that apply discounts to entire orders during price calculation. Order discounts are evaluated based on the order's total value or item count and automatically apply when conditions are met.
Use order discounts to implement:
Order discounts are for price discounts only
Order discounts are exclusively intended for calculating price discounts. Making other changes to the order via order discounts, such as adding extra items to the order or modifying the cart contents, may disrupt the resulting price calculation and produce invalid results.
The system supports three types of promotions:
Catalog discounts, order discounts, and free shipping can optionally require coupon codes for redemption. When configured with a coupon code, the promotion is only applied if the customer enters the matching code during checkout.
A promotion rule defines the logic for determining whether a promotion applies to an order and how to calculate the discount amount. You implement promotion rules as classes that inherit from OrderPromotionRule.
Terminology – Discounts vs. Promotions
In the administration interface, order discounts are managed under Promotions → Order discounts. However, in code, the related classes use "Promotion" in their names (e.g., OrderPromotionRule, OrderPromotionCandidate). This is because the underlying system uses a unified promotion framework.
A promotion candidate represents a potential discount that could be applied to an order. When a promotion rule evaluates an order, it returns an OrderPromotionCandidate containing:
OrderDiscountAmount – The calculated discount amountThe price calculation pipeline collects all candidates and applies the discount to the order total.
When multiple order promotions could apply to an order, the system automatically selects the best promotion based on the following criteria:
OrderDiscountAmount is selected.Order promotions are not cumulative. Only one order promotion can be applied per order.
Promotion rules can define configurable properties that store managers set when creating promotions in the administration interface. Properties are defined in a separate class implementing IPromotionRuleProperties and are decorated with editing components to generate the UI.
Implementing an order promotion rule involves creating two components:
IPromotionRuleProperties or inherits from OrderPromotionRuleProperties.OrderPromotionRuleBase or OrderPromotionRule.These two components work together – the properties class stores the configuration, and the logic class uses those configured values to evaluate orders during price calculation.
Sample implementation
See DancingGoatOrderPromotionRule.cs in the Dancing Goat project template for a sample implementation of an order discount rule.
The system provides OrderPromotionRuleProperties as a base class with common properties already defined and configurable via the administration interface:
You can inherit from this base class to include these standard fields and add your own custom properties. Use editing component annotations to generate the input fields for each property. The following sample demonstrates an extended properties class that inherits from OrderPromotionRuleProperties:
using Kentico.Xperience.Admin.Base.FormAnnotations;
using Kentico.Xperience.Admin.DigitalCommerce;
/// <summary>
/// Sample order promotion proerties class extending the default configrable prop
/// set with a custom fields. Uses editing component annotations.
/// </summary>
public class SampleOrderPromotionRuleProperties : OrderPromotionRuleProperties
{
// Base class properties include: DiscountValueType, DiscountValue,
// MinimumRequirementValueType, MinimumRequirementValue
// Add custom properties for your business logic
[CheckBoxComponent(
Label = "Additional checkbox field",
Order = 1)]
public bool MembersOnly { get; set; }
}The base class properties use negative Order values (e.g., -190, -180) to ensure they appear at the top of the form. Keep this in mind when positioning custom properties.
Alternatively, you can directly implement IPromotionRuleProperties, bypassing the inheritance entirely, if you need full control over all configuration properties used by the rule.
The framework provides two base classes for order promotion rules:
OrderPromotionRuleProperties. Provides built-in helper methods that work with the base properties, including automatic minimum requirement validation (purchase amount or item quantity).IPromotionRuleProperties directly. You must implement all discount calculation and validation logic yourself.Both base classes use the same generic type parameters:
TPromotionRuleProperties – The rule's properties class. Must inherit from OrderPromotionRuleProperties for OrderPromotionRule, or implement IPromotionRuleProperties for OrderPromotionRuleBase.TPriceCalculationRequest – The price calculation request type.TPriceCalculationResult – The price calculation result type.Register the rule using RegisterPromotionRuleAttribute with the following parameters:
PromotionType.Order to indicate this is an order promotion.When your properties class inherits from OrderPromotionRuleProperties, use OrderPromotionRule to access built-in helper methods:
GetDiscountAmount(linesSubtotalAfterLineDiscounts) – Calculates the discount based on DiscountValueType and DiscountValue from the base properties. Handles both percentage and fixed discounts, ensuring the discount never exceeds the order subtotal.GetDiscountValueLabel() – Returns a formatted label for display (e.g., "10%" or "$5.00").The following sample demonstrates a discount rule that enables only the default configuration. Since no additional configuration options are necessary, it uses the default properties class.
using System.Linq;
using CMS.Commerce;
using Kentico.Xperience.Admin.DigitalCommerce;
public class OrderPercentageDiscountRule
: OrderPromotionRule<OrderPromotionRuleProperties,
PriceCalculationRequest, PriceCalculationResult>
{
public override OrderPromotionCandidate GetPromotionCandidate(
IPriceCalculationData<PriceCalculationRequest, PriceCalculationResult> calculationData)
{
// Gets the total price after catalog discounts
var totalPrice = calculationData.Result.Items
.Sum(i => i.LineSubtotalAfterLineDiscount);
return new OrderPromotionCandidate
{
// Calculates the discount using the built-in helper method
// Handles both percentage and fixed discounts based
// on DiscountValueType (as configured via the admin UI)
OrderDiscountAmount = GetDiscountAmount(totalPrice)
};
}
}After implementing and registering an order promotion rule:
Use the RegisterPromotionRuleAttribute assembly attribute to register your promotion rule with the system. Without registration, the rule will not appear in the administration interface.
using CMS.Commerce;
[assembly: RegisterPromotionRule<OrderPercentageDiscountRule>(
"Acme.OrderPercentageDiscount",
PromotionType.Order,
"Order percentage discount")]Override the IsApplicable method to add high-level conditions that determine whether the promotion should be evaluated at all. This method runs once per promotion before calculating the discount, making it useful for performance optimization when you can skip the entire promotion early.
Built-in minimum requirement validation
When using OrderPromotionRule, the base class IsApplicable implementation automatically validates minimum requirements (purchase amount or item quantity) based on MinimumRequirementValueType and MinimumRequirementValue properties. You only need to override IsApplicable if you have additional custom conditions.
The evaluation pipeline works as follows:
IsApplicable is called once. If it returns false, the promotion is skipped entirely.GetPromotionCandidate is called to calculate the order discount.Use IsApplicable for checks that apply to the entire promotion context. For example, to restrict a promotion to registered members while keeping the built-in minimum requirement validation:
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CMS.Commerce;
// ...
public override async Task<bool> IsApplicable(
IPriceCalculationData<PriceCalculationRequest, PriceCalculationResult> calculationData,
CancellationToken cancellationToken)
{
BuyerIdentifier buyerIdentifier = calculationData.Request.BuyerIdentifier;
// Checks if the buyer is a registered member
if (buyerIdentifier == null || buyerIdentifier.Kind != BuyerIdentifierKind.Member)
{
return false;
}
// Calls the rest of the logic
return await base.IsApplicable(calculationData, cancellationToken);
}Use IsApplicable for custom conditions beyond what the built-in eligibility options provide, such as loyalty tier checks, purchase history requirements, or geographic restrictions.
Another common scenario is restricting a promotion to first-time customers who haven't placed any orders yet.
This rule assumes that:
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CMS.Commerce;
using CMS.DataEngine;
using Codesamples.Commerce;
using Kentico.Xperience.Admin.DigitalCommerce;
using Microsoft.AspNetCore.Http;
[assembly: RegisterPromotionRule<FirstTimeBuyerOrderPromotionRule>(
identifier: FirstTimeBuyerOrderPromotionRule.IDENTIFIER,
promotionType: PromotionType.Order,
name: "First time buyer discount"
)]
public class FirstTimeBuyerOrderPromotionRule
: OrderPromotionRule<OrderPromotionRuleProperties,
CodesamplesPriceCalculationRequest,
CodesamplesPriceCalculationResult>
{
public const string IDENTIFIER = "Acme.DigitalCommerce.FirstTimeBuyerOrderRule";
private readonly IInfoProvider<OrderInfo> orderProvider;
private readonly IInfoProvider<CustomerInfo> customerInfoProvider;
private readonly IHttpContextAccessor httpContextAccessor;
public FirstTimeBuyerOrderPromotionRule(
IInfoProvider<OrderInfo> orderProvider,
IInfoProvider<CustomerInfo> customerInfoProvider,
IHttpContextAccessor httpContextAccessor)
{
this.orderProvider = orderProvider;
this.customerInfoProvider = customerInfoProvider;
this.httpContextAccessor = httpContextAccessor;
}
public override async Task<bool> IsApplicable(
IPriceCalculationData<CodesamplesPriceCalculationRequest,
CodesamplesPriceCalculationResult> calculationData,
CancellationToken cancellationToken)
{
bool isFirstTimeCustomer =
await IsFirstTimeCustomer(
calculationData.Request.BuyerIdentifier, cancellationToken);
// Checks the base class conditions (minimum purchase requirements)
return isFirstTimeCustomer
&& await base.IsApplicable(calculationData, cancellationToken);
}
/// <summary>
/// Determines whether the customer is making their first purchase.
/// Returns true for authenticated users with no previous orders.
/// </summary>
private async Task<bool> IsFirstTimeCustomer(
BuyerIdentifier buyerIdentifier,
CancellationToken cancellationToken)
{
// Applies only to authenticated live site members
bool isAuthenticated =
httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated
?? false;
if (!isAuthenticated)
{
return false;
}
// Authenticated members without a buyer identifier
// are treated as first-time customers
if (buyerIdentifier == null)
{
return true;
}
// Resolves the customer ID from the buyer identifier
int customerId = await GetCustomerId(buyerIdentifier, cancellationToken);
// Members without an existing customer record
// are first-time customers
if (customerId == 0)
{
return true;
}
// If the customer exists, checks if
// they have any existing orders
int orderId = await orderProvider.Get()
.WhereEquals(nameof(OrderInfo.OrderCustomerID), customerId)
.Column(nameof(OrderInfo.OrderID))
.TopN(1)
.GetScalarResultAsync<int>(cancellationToken: cancellationToken);
// Only applies to customers with no previous orders
// (0 is the default int value when no rows match)
return !(orderId > 0);
}
/// <summary>
/// Gets the customer ID from a buyer identifier.
/// </summary>
private async Task<int> GetCustomerId(
BuyerIdentifier buyerIdentifier,
CancellationToken cancellationToken)
{
if (buyerIdentifier.Kind == BuyerIdentifierKind.Customer)
{
return buyerIdentifier.Value;
}
// Looks up the customer record for a registered member
int customerId = await customerInfoProvider.Get()
.WhereEquals(nameof(CustomerInfo.CustomerMemberID), buyerIdentifier.Value)
.Column(nameof(CustomerInfo.CustomerID))
.TopN(1)
.GetScalarResultAsync<int>(cancellationToken: cancellationToken);
return customerId;
}
public override OrderPromotionCandidate GetPromotionCandidate(
IPriceCalculationData<CodesamplesPriceCalculationRequest,
CodesamplesPriceCalculationResult> calculationData)
{
// Gets the total price after catalog discounts
var totalLinePriceAfterLineDiscount = calculationData.Result.Items
.Sum(i => i.LineSubtotalAfterLineDiscount);
// Apply discount for first-time buyers
return new OrderPromotionCandidate
{
OrderDiscountAmount = GetDiscountAmount(totalLinePriceAfterLineDiscount)
};
}
}All promotion rules inherit the PromotionID property from PromotionRuleBase. This property contains the ID of the promotion that the rule is currently being evaluated for, and is populated automatically by the framework.
Use PromotionID within IsApplicable to look up promotion-specific data or enforce per-promotion business rules. The following example queries OrderPromotionInfo records to verify that the current customer has not already received this specific promotion in a previous order:
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CMS.Commerce;
using CMS.DataEngine;
// ...
private readonly IInfoProvider<OrderPromotionInfo> orderPromotionInfoProvider;
private readonly IInfoProvider<OrderInfo> orderInfoProvider;
private readonly IInfoProvider<CustomerInfo> customerInfoProvider;
public SingleUsePerCustomerOrderRule(
IInfoProvider<OrderPromotionInfo> orderPromotionInfoProvider,
IInfoProvider<OrderInfo> orderInfoProvider,
IInfoProvider<CustomerInfo> customerInfoProvider)
{
this.orderPromotionInfoProvider = orderPromotionInfoProvider;
this.orderInfoProvider = orderInfoProvider;
this.customerInfoProvider = customerInfoProvider;
}
public override async Task<bool> IsApplicable(
IPriceCalculationData<CodesamplesPriceCalculationRequest,
CodesamplesPriceCalculationResult> calculationData,
CancellationToken cancellationToken)
{
BuyerIdentifier buyerIdentifier = calculationData.Request.BuyerIdentifier;
if (buyerIdentifier == null)
{
// Anonymous visitors are automatically excluded
return false;
}
// Resolves the customer ID from the buyer identifier
int customerId = await GetCustomerId(buyerIdentifier, cancellationToken);
if (customerId == 0)
{
// New customers without an existing record can use the promotion
return await base.IsApplicable(calculationData, cancellationToken);
}
// Checks if the customer has already used this specific promotion
// using the PromotionID property inherited from PromotionRuleBase
bool alreadyUsed = await HasCustomerUsedPromotion(
customerId, PromotionID, cancellationToken);
if (alreadyUsed)
{
return false;
}
return await base.IsApplicable(calculationData, cancellationToken);
}
/// <summary>
/// Resolves the customer ID from a buyer identifier.
/// </summary>
private async Task<int> GetCustomerId(
BuyerIdentifier buyerIdentifier,
CancellationToken cancellationToken)
{
if (buyerIdentifier.Kind == BuyerIdentifierKind.Customer)
{
return buyerIdentifier.Value;
}
// Resolves the customer record for a registered member
CustomerInfo? customer = (await customerInfoProvider.Get()
.WhereEquals(nameof(CustomerInfo.CustomerMemberID), buyerIdentifier.Value)
.TopN(1)
.GetEnumerableTypedResultAsync(cancellationToken: cancellationToken))
.FirstOrDefault();
return customer?.CustomerID ?? 0;
}After price calculation, you can access promotion information from the result to display discount details to customers or for reporting purposes.
Order promotion candidates are stored in the PromotionData.OrderPromotionCandidates collection on the calculation result. Each candidate is wrapped in IPriceCalculationPromotionCandidate<OrderPromotionCandidate> which includes an Applied flag indicating whether the promotion was applied to the order:
using System.Linq;
using System.Threading.Tasks;
using CMS.Commerce;
// ...
public static async Task AccessPromotionDataExample(
IPriceCalculationService<PriceCalculationRequest, PriceCalculationResult> priceCalculationService,
PriceCalculationRequest request)
{
// Calculates the price
PriceCalculationResult calculationResult =
await priceCalculationService.Calculate(request);
// Gets the applied order promotion
IPriceCalculationPromotionCandidate<OrderPromotionCandidate>? appliedOrderPromotion =
calculationResult.PromotionData.OrderPromotionCandidates
.FirstOrDefault(p => p.Applied);
if (appliedOrderPromotion is not null)
{
decimal discountAmount =
appliedOrderPromotion.PromotionCandidate.OrderDiscountAmount;
// Use the discount information...
}
}You can extend OrderPromotionCandidate to include additional data needed for display or business logic.
To access the custom promotion candidate in calculation results, cast the candidate from the OrderPromotionCandidates collection:
using System.Linq;
using System.Threading.Tasks;
using CMS.Commerce;
// ...
public static async Task AccessCustomCandidateExample(
IPriceCalculationService<PriceCalculationRequest, PriceCalculationResult> priceCalculationService,
PriceCalculationRequest request)
{
// Calculates the price
PriceCalculationResult calculationResult
= await priceCalculationService.Calculate(request);
// Gets the applied order promotion and casts to custom type
IPriceCalculationPromotionCandidate<OrderPromotionCandidate>? appliedPromotion =
calculationResult.PromotionData.OrderPromotionCandidates
.FirstOrDefault(p => p.Applied);
if (appliedPromotion is not null) {
// Checks if the candidate is a custom type and works with the results
if (appliedPromotion?.PromotionCandidate is DisplayableOrderPromotionCandidate displayable)
{
string label = displayable.DisplayLabel;
// Process the label...
}
}
}