Recently we got this question on how to create package-equivalent promotion type in Episerver Commerce, from https://world.episerver.com/forum/developer-forum/Episerver-Commerce/Thread-Container/2018/2/is-there-a-built-in-group-discount/

I already recommended to use package for such purpose, because of several reasons:

  • Package is a builtin feature, and is fully supported by the framework, both on UI level and API level.
  • It has been well tested and is very reliable to use.

However in a real world implementation, it might not be easy to just add package implementation. One reason would be if you rely on an external PIM to handle your catalog. Configuring it to support package can not be trivial.

So why not try to implement a package equivalent promotion in the promotion engine, to see if it works.

Well, because I can. As a framework developer I don’t get many changes to create new types of promotion. So it’s better to keep practicing and keep my mind sharp.

First step would be defining the promotion type. For simplicity, we would want to map 1:1 between a promotion and a package, so the promotion should contain the information the items in each package. It should look like this

    [ContentType(GUID = "CC54AC32-835D-4B50-AE3A-F3C127EC5E38", GroupName = "entrypromotion", Order = 10200)]
    [ImageUrl("Images/BuyQuantityPayFixedAmount.png")]
    public class BuySelectedItemsPayFixedAmount : EntryPromotion
    {
        [DistinctList]
        [AllowedTypes(typeof(VariationContent))]
        [Display(Order = 20)]
        public virtual IList<ContentReference> Items { get; set; }

        /// <summary>
        /// The fixed price to be applied.
        /// </summary>        
        [Display(Order = 20)]
        [PromotionRegion(PromotionRegionName.Discount)]
        public virtual IList<Money> Amounts { get; set; }
    }

Here we define a list of ContentReference , and limit it to VariationContent (and its derived classes) only, and also the fixed amount of money the customer would have to pay if they buy these items. Thanks to the support from CMS 11, we get this nice, clean UI out of the box without any additional efforts:

It looks nice, isn’t it? And you get that for almost free

Here I created a promotion which, would give a fixed $50 price if the customers buy exactly two items we selected.

(This is also why the builtin “Buy quantity get fixed price” does not work for us – the customers can buy 2 items of A and that’s enough. We would want them to buy 1 item of A and 1 item of B).

It’s time to implement the processor. It’s not particularly complicated, so I will leave it here. If I have time, I will come back and explain it later.

namespace EPiServer.Reference.Commerce.Site.Features.Promotions
{

    [ServiceConfiguration(Lifecycle = ServiceInstanceScope.Singleton)]
    public class BuySelectedItemsPayFixedAmountProcessor : EntryPromotionProcessorBase<BuySelectedItemsPayFixedAmount>
    {
        private readonly FulfillmentEvaluator _fulfillmentEvaluator;
        private readonly LocalizationService _localizationService;
        private readonly RedemptionDescriptionFactory _redemptionDescriptionFactory;
        private readonly ReferenceConverter _referenceConverter;

        /// <summary>
        /// Initializes a new instance of a <see cref="BuySelectedItemsPayFixedAmountProcessor" />.
        /// </summary>
        /// <param name="fulfillmentEvaluator">The service that is used to evaluate the fulfillment status of the promotion.</param>
        /// <param name="localizationService">The localization service.</param>
        /// <param name="redemptionDescriptionFactory">Factory for creating <see cref="RedemptionDescription"/>s.</param>
        /// <param name="referenceConverter"></param>
        public BuySelectedItemsPayFixedAmountProcessor(
            FulfillmentEvaluator fulfillmentEvaluator,
            LocalizationService localizationService,
            RedemptionDescriptionFactory redemptionDescriptionFactory, ReferenceConverter referenceConverter)
            : base(redemptionDescriptionFactory)
        {
            ParameterValidator.ThrowIfNull(() => fulfillmentEvaluator, fulfillmentEvaluator);
            ParameterValidator.ThrowIfNull(() => localizationService, localizationService);

            _fulfillmentEvaluator = fulfillmentEvaluator;
            _localizationService = localizationService;
            _redemptionDescriptionFactory = redemptionDescriptionFactory;
            _referenceConverter = referenceConverter;
        }

        /// <inheritdoc/>
        protected override PromotionItems GetPromotionItems(BuySelectedItemsPayFixedAmount promotionData)
        {
            return
                new PromotionItems(
                    promotionData,
                    new CatalogItemSelection(promotionData.Items, CatalogItemSelectionType.Specific,
                        false),
                    new CatalogItemSelection(promotionData.Items, CatalogItemSelectionType.Specific,
                        false));
        }

        /// <inheritdoc />
        protected override RewardDescription Evaluate(BuySelectedItemsPayFixedAmount promotionData, PromotionProcessorContext context)
        {
            var applicableCodes = context.OrderForm.GetAllLineItems().Where(item => !item.IsGift).Select(l => l.Code);
            var fulfillmentStatus = GetFulfillmentStatus(applicableCodes, promotionData);

            if (!fulfillmentStatus.HasFlag(FulfillmentStatus.Fulfilled))
            {
                return NotFulfilledRewardDescription(promotionData, context, fulfillmentStatus);
            }

            context.AddConditionalItems(promotionData.ContentLink, applicableCodes, promotionData.Items.Count);

            var redemptions = GetRedemptions(promotionData, context, applicableCodes);

            return new RewardDescription(
                fulfillmentStatus,
                redemptions,
                promotionData,
                GetAmountReward(promotionData, context.OrderGroup.Currency).Amount,
                0,
                RewardType.FixedPrice,
                fulfillmentStatus.GetRewardDescriptionText(_localizationService));
        }

        private FulfillmentStatus GetFulfillmentStatus(IEnumerable<string> applicableCodes, BuySelectedItemsPayFixedAmount promotionData)
        {
            var items = new HashSet<string>(applicableCodes);
            var packageItems = new HashSet<string>(promotionData.Items.Select(c => _referenceConverter.GetCode(c)));
            if (packageItems.IsSubsetOf(items))
            {
                return FulfillmentStatus.Fulfilled;
            }
            else if (items.IsSubsetOf(packageItems))
            {
                return FulfillmentStatus.PartiallyFulfilled;
            }

            return FulfillmentStatus.NotFulfilled;
        }

        /// <inheritdoc />
        protected override bool CanBeFulfilled(BuySelectedItemsPayFixedAmount promotionData, PromotionProcessorContext context)
        {
            return promotionData.Items != null 
                && promotionData.Items.Any() 
                && GetLineItems(context.OrderForm).Count() >= promotionData.Items.Count
                && GetAmountReward(promotionData, context.OrderGroup.Currency).Amount > 0;
        }

        /// <inheritdoc/>
        protected override RewardDescription NotFulfilledRewardDescription(BuySelectedItemsPayFixedAmount promotionData, PromotionProcessorContext context, FulfillmentStatus fulfillmentStatus)
        {
            return new RewardDescription(
                fulfillmentStatus,
                Enumerable.Empty<RedemptionDescription>(),
                promotionData,
                0,
                0,
                RewardType.FixedPrice,
                FulfillmentStatus.NotFulfilled.GetRewardDescriptionText(_localizationService));
        }

        /// <inheritdoc />
        protected override IEnumerable<RedemptionDescription> GetRedemptions(BuySelectedItemsPayFixedAmount promotionData, PromotionProcessorContext context, IEnumerable<string> applicableCodes)
        {
            return _redemptionDescriptionFactory.GetRedemptionDescriptions(promotionData, context, applicableCodes, int.MaxValue, promotionData.Items.Count);
        }

        private Money GetAmountReward(BuySelectedItemsPayFixedAmount promotion, Currency currency)
        {
            var money = new Money(0, currency);
            if (promotion.Amounts != null && promotion.Amounts.Any(m => m.Currency == currency && m.Amount > 0))
            {
                money = promotion.Amounts.FirstOrDefault(amount => amount.Currency == currency);
            }

            return money;
        }
    }
}

 

And let’s test it. I disabled all default promotions in Quicksilver to make it easier to see the result. The combination of promotions will of course more complex, but I trust the engine we’ve built to handle such things.

And it actually works:

 

Great success!

 

This is of course a quick and dirty implementation, and I intentionally skip many small designs and considerations:

  • It’s not easy to handle the cases when you would want a package which contains an item with quantity > 1. For example, one table and 4 chairs. Well, you can create a custom widget to do such thing, but again, it’s not trivial. I’m not doing it because it will no longer be quick and dirty (it is still dirty, just not “quick”)
  • We didn’t take multiple shipments into account. If a customer splits item A in the first shipment and item B in the second shipment, does that still count for the promotion, or not.
  • This is without intensive testing. I fixed some obvious bugs that prevent the promotion from working, but there is no guarantee this will be bug-free. DON’T use this in your production site because I wrote it. Well, that would be an honor for me, but I will not be blamed by any damage it might cause.

All in all, this is pretty a proof of concept – to demonstrate what our promotion engine can do. But as they usually say, because you CAN, does not mean that you SHOULD.

Package is a far better way to do same thing, and it’s already there for you.

 


1 Comment

Mattias Bomelin · June 28, 2019 at 3:17 am

Thanks for this, it was good inspiration. One scenario where this is very helpful is combination promotions. For example “Gloves and hat $25”, or “Skis, bindings and shoes $499”.
That is not easy to solve using Package, that would need a configurable package implementation.

Leave a Reply

Your email address will not be published. Required fields are marked *