Hide menu Last updated: Sep 30 2016

Custom promotions

This topic explains how to create a custom promotion in Episerver Commerce.

Creating a custom promotion

To create a new promotion type, you need to create these:

  • PromotionData. The definition of a promotion, that is, the metadata needed to run the promotion.
  • processor. Evaluates if a promotion should be applied.

PromotionData

PromotionData should contain properties needed to evaluate an order and apply a reward. For example, a PromotionData contains entries to which the promotion applies and the promotion. Add to this type any property that a marketer should edit when setting up a promotion.  The base class provides basic metadata properties, such as name and valid dates.

PromotionData comes in three types.

Note: When you create a promotion, inherit from one of the following types; never from PromotionData directly.

  • EntryPromotion. Applies reward to a specific entry.
  • OrderPromotion. Applies reward to full order.
  • ShippingPromotion. Applies reward to order's shipping cost.

Connecting a description to a property using colors

You can use a color to connect a property to a specific part of a promotion description. Colors make it easier to communicate what each property means in the UI. By adding the PromotionRegion attribute to a promotion property, it is marked for being included in the connection to the description. You also can use the PromotionRegion attribute on a block property, to connect properties in a block to the same part of the promotion description.

The attribute constructor takes a single string parameter that determines the property's color. Some pre-defined constants of the type EPiServer.Commerce.Marketing.PromotionRegionName are used by internal promotion types. You should also use those constants when creating custom promotion types.

Like all content types, it is possible to translate the name and description by adding an element in the XML file whose name matches the content type. You can use a new formdescription sub-element to connect properties with the description. By adding "{regionname}some text{/}" in the text, the part "some text" gets the color matching that region. Both {regionname} and {/} are removed before the UI displays the description.

Example

The following example shows a custom promotion type with two properties: one block and one number. ConditionBlock is colored in the UI with a css-class indicating it is a condition, while Percentage gets a css-class indicating it is a reward.

[ContentType(GUID = "76EBFEFF-2CFB-42F2-B4A3-EA5EA5A41515")]
public class CustomPromotion : EntryPromotion
{
    [PromotionRegion(PromotionRegionName.Condition)]
    public virtual CustomPromotionBlock Conditions { get; set; }
 
    [PromotionRegion(PromotionRegionName.Reward)]
    public virtual int Percentage { get; set; }
}
 
[ContentType(GUID = "15B7BEA8-967A-4C5C-87F3-7346E71CBCC9")]
public class CustomPromotionBlock : BlockData
{
    public virtual int RequiredQuantity { get; set; }
 
    public virtual IList<ContentReference> Targets { get; set; }
}

The description is specified in the formdescription element in the translation xml.

<custompromotion>
  <name>My custom promotion</name>
  <create see="/contenttypes/custompromotion/name" />
  <description>Buy at least X items from categories/entries and get a % discount on the cheapest item.</description>
  <formdescription>Buy {condition} at least X items from categories/entries{/} and get {reward}a % discount{/} on the cheapest item.</formdescription>
</custompromotion>

Promotion processors

After creating promotion data, you need to make a processor.

Note: If you want to inherit a promotion data only for the purpose of adding properties, not for evaluation, you can reuse the base promotion's processor.

The processor evaluates if a promotion should apply a reward to an order. You can implement the IPromotionProcessor interface directly, but the recommended way is to inherit from the abstract EntryPromotionProcessorBase<TEntryPromotion>, OrderPromotionProcessorBase<TOrderPromotion>, or  ShippingPromotionProcessorBase<TShippingPromotion> depending on the type of promotion being created.

PromotionProcessorBase has one abstract method to be implemented, Evaluate. The method is supplied with a PromotionData, and a PromotionProcessorContext object that contains information about the current order. It's responsible for evaluating if a reward should apply. The Evaluate method returns a RewardDescription.

RewardDescription

A reward description contains information about if and how a reward is applied. Its most important properties are:

  • A list of redemption descriptions, one for each of the maximum amount of redemptions that could be applied to the current order. This does not have to take redemption limits into consideration, that is handled by the promotion engine.
  • A reward type. Depending on the type, the promotion value is read from the properties UnitDiscount, Percentage or Quantity.
  • A status flag. Indicates if a promotion is not, partially, or fully fulfilled.
  • A saved amount. The amount by which this reward reduces the order cost. Is set by the promotion engine; should not be set in the promotion processor.

RedemptionDescription

A RedemptionDescription describes one redemption of a reward. Its primary goal is to identify the objects to which the redemption should apply. Other than that, the RedemptionDescription also says how much this redemption saves on the order, and has a status flag that is set if the promotion engine (for some reason) decides not to apply this redemption. Most commonly, that is because a redemption limit was reached.

Depending on which type of promotion the reward gives (entry, order or shipping), different types of affected objects are used. They can be found in either AffectedEntriesAffectedShipments or AffectedOrders. Use the CreateRedemptionDescription method on the promotion processor base classes to populate the redemption with the correct type of affected objects.

Affected entries

The promotion engine creates a price matrix for all the items in the order form.

The price matrix, OrderFormPriceMatrix, is accessible through the EntryPrices property of the PromotionProcessorContext object. PromotionProcessorContext is passed to the Evaluate method as one of the arguments.

The matrix remember which codes, and quantity for the codes, that have been received. The second ExtractEntries call starts to receive entries where the first call ended. This makes it easy to create several redemptions by calling ExtractEntries in a loop, and create one RedemptionDescription inside the loop.

Extract entries

The price matrix has one public method, called ExtractEntries, with two overloads. Both overloads takes entry codes and quantity as parameters. One of them also contains an action for getting the entries in a specific order. If no specific order is specified, MostExpensiveFirst is used.

Entry codes

The entry codes is an IEnumerable<string>, where entry codes that should be received are defined. The items in the order, which has been defined in the condition part of the promotion data, are normally used as entry codes.

Quantity

Defines the number of items to receive. The quantity would be 3 in the example "Buy 3, get the cheapest for free".

Sort method

An action, where it's possible to define a custom ordering of the affected entries. The order might be importing, because only the top 3 items are received in the example "Buy 3, get the cheapest for free".

Two predefined sort orders can be passed in as the sortMethod argument: MostExpensiveFirst and CheapestFirst. Both sort methods are static methods on the OrderFormPriceMatrix class, which is accessible through the EntryPrices method on PromotionProcessorContext.

Set promotion range

SetDiscountRange defines which affected entries, received from the price matrix, should receive a discount. The method has two parameters: skip and take. The example "Buy 3, get the cheapest for free" should make the call SetDiscountRange(2, 1), which skips the first two items, and gives the promotion (free) to the third. If all items get the promotion, e.g. there is 20% off all items, SetDiscountRange does not have to be called.

Promotion engine priority handling

The Discount Priorities view lets a merchandiser manage priority and exclusivity for promotions. Using this view, a merchant can indicate priority order (if an item is eligible for multiple promotions) and which promotions cannot be combined with the current one. The view uses the following PromotionData properties.

  • Priority. Higher priority promotions are evaluated before lower priority ones. 
  • ExcludedPromotions. List of excluded promotions; ignored when the promotion is evaluated.

Adding money collections to your promotion

A promotion may need a collection of currencies and amounts as part of the condition evaluation or reward logic. To achieve this, add an IList<Money> property to the custom promotion class. When used on a promotion type, such a Money collection is tightly coupled with the currencies available on the parent campaign.

Initially, currencies related the promotion's campaign market have an amount of zero. Changing a campaign's market also changes available currencies in the property. Consider this when developing promotion processors, because you must decide the desired behavior when one, or even all, amounts are set to zero.

The same applies to the MonetaryReward type, which uses an IList<Money> property to store some of its values.

Adding help text for custom groups

To include help text for custom groups on a campaign or promotion form, add the Display attribute with the GroupName property set to the name of a specific node within the <groups> section in the resource files. The groups sections are content-type specific, so should be placed under <contenttypes> node for your specific type.

Example:

[Display(GroupName = "MyNewGroup")]
public virtual decimal Money { get; set; }

The resource file will be like this
<contenttypes>
  <nameofcontenttype>
    <groups>
      <mynewgroup>
        <help>This is help text for my new group</help>
      </mynewgroup>
    </groups>

See also Property attributes.

Promotion processor example

Below is a more complete example of a percentage-based promotion processor.

using EPiServer.Commerce.Extensions;
using EPiServer.Commerce.Marketing;
using EPiServer.Framework.Localization;
using EPiServer.ServiceLocation;
using System.Collections.Generic;
using System.Linq;

namespace CodeSamples.EPiServer.Commerce.Marketing
{
    #region PercentagePromotionProcessorSample

    /// <summary>
    /// Sample of a promotion processor for <see cref="PercentagePromotionSample"/>.
    /// </summary>
    [ServiceConfiguration(Lifecycle = ServiceInstanceScope.Singleton)]
    public class PercentagePromotionProcessorSample : EntryPromotionProcessorBase<PercentagePromotionSample>
    {
        private readonly CollectionTargetEvaluator _targetEvaluator;
        private readonly FulfillmentEvaluator _fulfillmentEvaluator;
        private readonly LocalizationService _localizationService;

        /// <summary>
        /// Creates a new instance of a <see cref="PercentagePromotionProcessorSample"/>.
        /// </summary>
        /// <param name="targetEvaluator">The service that is used to evaluate an order against a promotion's target properties.</param>
        /// <param name="fulfillmentEvaluator">The service that is used to evaluate the fulfillment status of the promotion.</param>
        /// <param name="localizationService">The service that is used to get localized strings.</param>
        public PercentagePromotionProcessorSample(
            CollectionTargetEvaluator targetEvaluator, 
            FulfillmentEvaluator fulfillmentEvaluator,
            LocalizationService localizationService)
        {
            _targetEvaluator = targetEvaluator;
            _fulfillmentEvaluator = fulfillmentEvaluator;
            _localizationService = localizationService;
        }

        /// <summary>
        /// Evaluates a promotion against an order form.
        /// </summary>
        /// <param name="promotionData">The promotion to evaluate.</param>
        /// <param name="context">The promotion processor context.</param>
        /// <returns>
        /// A <see cref="RewardDescription" /> telling whether the promotion was fulfilled,
        /// which items the promotion was applied to and to which discount percentage.
        /// </returns>
        protected override RewardDescription Evaluate(PercentagePromotionSample promotionData, PromotionProcessorContext context)
        {
            var lineItems = GetLineItems(context.OrderForm);
            var condition = promotionData.Condition;

            var applicableCodes = _targetEvaluator.GetApplicableCodes(lineItems, condition.Items, condition.MatchRecursive);

            var fulfillmentStatus = _fulfillmentEvaluator.GetStatusForBuyQuantityPromotion(
                applicableCodes, 
                lineItems,
                condition.RequiredQuantity,
                condition.PartiallyFulfilledThreshold);

            var affectedEntries = context.EntryPrices.ExtractEntries(applicableCodes, condition.RequiredQuantity);

            return RewardDescription.CreatePercentageReward(
                fulfillmentStatus,
                GetRedemptions(applicableCodes, promotionData, context),
                promotionData,
                promotionData.PercentageDiscount,
                fulfillmentStatus.GetRewardDescriptionText(_localizationService));
        }

        /// <summary>
        /// Gets the items related to a promotion.   
        /// </summary>
        /// <param name="promotionData">The promotion data to get items for.</param>
        /// <returns>
        /// The promotion condition and reward items.
        /// </returns>
        protected override PromotionItems GetPromotionItems(PercentagePromotionSample promotionData)
        {
            var specificItems = new CatalogItemSelection(
                promotionData.Condition.Items, 
                CatalogItemSelectionType.Specific,
                promotionData.Condition.MatchRecursive);

            return new PromotionItems(promotionData, specificItems, specificItems);
        }

        /// <summary>
        /// Verify that the current promotion can potentially be fulfilled.
        /// </summary>
        /// <remarks>
        /// This method is intended to be a very quick pre-check to avoid doing more expensive operations.
        /// In this case that a positive discount percentage has been set, and that the cart is not empty.
        /// </remarks>
        /// <param name="promotionData">The promotion to evaluate.</param>
        /// <param name="context">The context for the promotion processor evaluation.</param>
        /// <returns>
        /// <c>true</c> if the current promotion can potentially be fulfilled; otherwise, <c>false</c>.
        /// </returns>
        protected override bool CanBeFulfilled(PercentagePromotionSample promotionData, PromotionProcessorContext context)
        {
            if (promotionData.PercentageDiscount <= 0)
            {
                return false;
            }

            var lineItems = GetLineItems(context.OrderForm);
            if (!lineItems.Any())
            {
                return false;
            }
            
            return true;
        }

        /// <summary>
        /// Gets all <see cref="AffectedItem"/>s affected by a given promotion.
        /// </summary>
        /// <param name="affectedItems">A collection of <see cref="PriceEntry"/>s to be checked against a promotion.</param>
        /// <param name="promotion">The promotion used to evaluate the line items.</param>
        /// <returns>A list of applicable <see cref="RedemptionDescription"/>s</returns>
        private IEnumerable<RedemptionDescription> GetRedemptions(IEnumerable<string> applicableCodes, PercentagePromotionSample promotionData, PromotionProcessorContext context)
        {
            var redemptions = new List<RedemptionDescription>();

            var requiredQuantity = promotionData.Condition.RequiredQuantity;
            var maxRedemptions = GetMaxRedemptions(promotionData.RedemptionLimits);

            for (int i = 0; i < maxRedemptions; i++)
            {
                var affectedEntries = context.EntryPrices.ExtractEntries(applicableCodes, requiredQuantity);
                if (affectedEntries == null)
                {
                    break;
                }

                redemptions.Add(CreateRedemptionDescription(affectedEntries));
            }

            return redemptions;
        }
    }

    #endregion
}

Comments