Add discountThe latest version of EPiServer Commerce (v8.13.4 at the time of writing) has an all new promotion engine that is currently in beta. The best part about the new engine is that the EPiServer Commerce 9 promotion engine is designed to be more developer friendly and extensible than the previous one.

This post is a quick example of how we can build a custom promotion with the new EPiServer Commerce promotion engine. 

The promotion type I want to build is "Match this visitor group, get a %'ge off your order". This means our users could segment their users in any way they want and give a discount as a reward.

At the most basic level there are three things we need for a custom promotion in EPiServer Commerce 9:

Promotion Data (inherit EntryPromotion, OrderPromotion or ShippingPromotion) 

This is the model that makes up the promotion. In our case this is the visitor group we need to match and the discount percentage to apply to the order

Promotion Processor (inherit PromotionProcessorBase<T>)

This processor evaluates eligibility for the promotions and returns an instance of IPromotionResult

Promotion Result (implements IPromotionResult)

This is returned by our processor and does the work of applying the promotion


OK so lets look at some code. There are a three things we need to build out for a custom promotion as described above:

Promotion Data

This at a basic level is actually just a content type in EPiServer. The important part is that the type inherits from EntryPromotion, ShippingPromotion or OrderPromotion. In example I am creating an OrderPromotion:

[ContentType(GUID = "971192AD-8FD7-4CF0-9464-B8F432A06067"
    , DisplayName = "Visitor Group promotion"
    , Description = "Customers who match one of the selected visitor group(s) will be eligible for a %'ge discount")
]
public class VisitorGroupPromoData : OrderPromotion
{
    [Display(
        Name = "Matching visitor group(s)"
        ,
        Description = "Customers who match one of the selected visitor group(s) will be eligible for the promotion")
    ]
    [SelectMany(SelectionFactoryType = typeof (VisitorGroupSelectionFactory))]
    public virtual string MatchingVisitorGroups { get; set; }

    [Display(
        Name = "Discount Percentage"
        , Description = "Elligible customers will receive this %'ge discount off their orders")]
    [Range(0, 99)]
    public virtual int DiscountPercent { get; set; }
}

This uses a simple visitor group selection factory to get a list of visitor groups:

public class VisitorGroupSelectionFactory : ISelectionFactory
{
    public IEnumerable GetSelections(ExtendedMetadata metadata)
    {
        var repository = ServiceLocator.Current.GetInstance();
        return repository.List()
            .OrderBy(v => v.Name)
            .Select(v => new SelectItem() {Text = v.Name, Value = v.Name})
            .ToList();
    }
}

Now when working with the promotion is the new marketing UI in EPiServer Commerce properties of the promotion are rendered in a familiar looking form view in EPiServer:

Custom promotion forms view

Promotion Processor

This processor evaluates eligibility for the promotions and returns an instance of IPromotionResult. In the example case we simply need to work out if the current user has matched a visitor group or not:

public class VisitorGroupPromoProcessor : PromotionProcessorBase
{
    public override IPromotionResult Evaluate(IOrderGroup orderGroup, VisitorGroupPromoData promotionData)
    {
        // Check if the user is elligible for the promotion, by testing if they in a one of selected visitor group(s)
        var matchedGroup = visitorGroupMatched(promotionData);
        if (string.IsNullOrEmpty(matchedGroup))
        {
            return new VisitorGroupPromoResult(
                "No discount applied",
                FulfillmentStatus.NotFulfilled,
                0,
                null);
        }
        else
        {
            return new VisitorGroupPromoResult(
                promotionData.DiscountPercent.ToString() +
                " % discount applied to order as matched on visitor group: " + matchedGroup,
                FulfillmentStatus.Fulfilled,
                promotionData.DiscountPercent,
                orderGroup);
        }
    }

    private string visitorGroupMatched(VisitorGroupPromoData promotionData)
    {
        // Example code, need to make more testable and defensive, remove HttpContext depedency
        if (HttpContext.Current != null && HttpContext.Current.User != null)
        {
            var user = HttpContext.Current.User;
            var vgHelper = new VisitorGroupHelper();
            foreach (var visitorGroup in promotionData.MatchingVisitorGroups.Split(','))
            {
                if (vgHelper.IsPrincipalInGroup(user, visitorGroup))
                {
                    return visitorGroup;
                }
            }
        }

        return string.Empty;
    }
}

Promotion Result (IPromotionResult)

This actually does the work of applying the promotion to the IOrderGroup.

Note: I put this code together quickly so there is a high chance it contains errors!

public class VisitorGroupPromoResult : IPromotionResult
{
    private readonly string _description;
    private readonly FulfillmentStatus _status;
    private readonly int _discountPercent;
    private readonly IOrderGroup _orderForm;

    public VisitorGroupPromoResult()
        : this("No discount applied", FulfillmentStatus.NotFulfilled, 0, null)
    {
    }

    public VisitorGroupPromoResult(
        string description,
        FulfillmentStatus status,
        int discountPercent,
        IOrderGroup orderForm)
    {
        this._description = description;
        this._status = status;
        this._discountPercent = discountPercent;
        this._orderForm = orderForm;
    }

    public string Description
    {
        get { return this._description; }
    }

    public FulfillmentStatus Status
    {
        get { return this._status; }
    }


    public IEnumerable ApplyReward()
    {
        // Note: This is quickly put together POC code, not tested
        // and/or validated and is likely to be broken!
        if (this._status == FulfillmentStatus.Fulfilled && this._orderForm != null)
        {
            // Get the sub-total for the order so we can apply the discount - need to
            // check if it is appropriate to use IOrderFormCalculator here...
            var orderFormCalculator = ServiceLocator.Current.GetInstance();
            Money subTotal = orderFormCalculator.GetSubTotal(this._orderForm.Forms.First(), this._orderForm.Currency);

            //Calculate total discount
            var totalDiscount = (subTotal*(this._discountPercent/100M)).Amount;

            //Add the order discount amount to the first line item. Code needs to be way more defensive here
            var affectedItem = this._orderForm.Forms.First().Shipments.First().LineItems.First();
            affectedItem.OrderLevelDiscountAmount = totalDiscount;

            // Check perf on ReferenceConverter
            var rc = ServiceLocator.Current.GetInstance();
            var link = rc.GetContentLink(affectedItem.Code);

            PromotionInformation promotionInformation = new PromotionInformation
            {
                Description = this.Description,
                SavedAmount = totalDiscount,
                ContentLink = link,
                IsActive = true
            };
            yield return promotionInformation;
        }
        else
        {
            PromotionInformation noReward = new PromotionInformation
            {
                Description = this._description,
                IsActive = false
            };
            yield return noReward;
        }
    }
}

Testing out the promotion

The easiest way to test the new promotion engine at the moment is to switch it on so new promotions are evaluation from the legacy workflows. This is achieved by config or through code as described here: Workflows [beta] Based on this documentation the following initiliasation module module can be used to switch on the new promotions engine:

[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class SwitchOnNewPromoEngine : IInitializableModule
{
    public void Initialize(InitializationEngine context)
    {
        var featureSwitch = ServiceLocator.Current.GetInstance();
        featureSwitch.InitializeFeatures();
        featureSwitch.Features.Add(new WorkflowsVNext());
        featureSwitch.EnableFeature(WorkflowsVNext.FeatureWorkflowsVNext);
    }

    public void Uninitialize(InitializationEngine context) { }
}

Finally

The code is very quick POC on an early release of EPiServer Commerce 9 so please only take it as an example of how you put a custom promotion together in EPiServer Commerce 9. The new EPiServer Commerce 9 promotion engine gives us the ability to programmatically create promotions and apply them to our basket in a way that's far more simple than previously and uses a familiar code-first approach.

Part 2

Part two of this post looks at how we can make working with promotions a little more visual for our users:

Read: Part 2


Comments

Recommendations for you