Solution to the purchasing of the same product with different pricing tiers depending on the customer's choice

We enabled selling one product, an online video, at different prices, depending on whether you want a certificate, when you have one price (individual pricing tier), or you don’t need a certificate, when you have a second price (family pricing tier) or you are buying this product for a group of people with a certificate, when you have a third price (team pricing tier). Customer can choose to buy the product at any of the offered pricing tiers or make any combination of the product pricing tiers he/she wants. If a customer buys, for example, one product at the individual pricing tier for himself/herself and the same product at the family pricing tier for a friend, we need 2 cartlines as the end result. This can be achieved with variants, but we didn’t use them because it’s the same product and by using variants we would triple the data and that would mean more work for content editors. The other solution is digital tags, but it is also unsatisfactory for the purpose because if you add several different pricing tiers of the product you get the matching number of cartlines, and we want as many cartlines as there are different pricing tiers of the product chosen by a customer, with a quantity of each pricing tier in it's cartline.

Our solution is to add in the IAddCartLinePipelineblock StSplitCartlineBlock after AddCartLineBlock in which we will make some logic and split the cartline.

public void ConfigureServices(IServiceCollection services)
        {
            var assembly = Assembly.GetExecutingAssembly();
            services.RegisterAllPipelineBlocks(assembly);

            // Configure pipelines
            services.Sitecore().Pipelines(config => config

                .ConfigurePipeline<IAddCartLinePipeline>(
                    configure =>
                    {
                        configure.Add<StSplitCartlineBlock>().After<AddCartLineBlock>();
                    })

                .ConfigurePipeline<IPopulateLineItemPipeline>(x =>
                {
                    x.Replace<CalculateCartLinePriceBlock, StCalculateCartLinePriceBlock>();
                })

                .ConfigurePipeline<IConfigureServiceApiPipeline>(configure => configure.Add<ConfigureServiceApiBlock>())
            );

           services.RegisterAllCommands(assembly);
        }

Where do we store this individual, family and team pricing tiers? In business tools, using custom price cards. We based our solution on this blog https://www.brimit.com/blog/sitecore-commerce-9-custom-membership-pricing-part1, with the significant difference - their customer is gold and has a gold price exclusively, and our customer can buy both gold and silver product in one order.

Pricing tiers

In website, on product details page, it appears as follows:

Select your option

The first challenge was that the price of the product remains unknown until the customer selects the option (pricing tier) and clicks the add to cart button, so we needed to send the price and price tier (individual, family, team) to the custom endpoint in the engine project and to set the price there. Details on achieving this are not the focus of this blog, but in short terms we made CustomAddCartLine controller, CustomCartLineArgument, CustomAddCartLineCommand modeled on AddCartLine controller, CartLineArgument and AddCartLineCommand. In CustomAddCartLineCommand we made a TypeTierComponent that will eventually be a component of cartline and we placed it in context. In StSplitCartlineBlock block this component will be set to cartline and as a result that information will be available through the whole process of buying product and cover future cases if that information needs to be sent to GP. Adding a component to the cartline is an example of how to send more parameters to the pipeline than the input argument has. In CustomAddCartLineCommand we are calling IAddCartLinePipeline.

var currentTypeComponent = new TypeTierComponent
{
    TypeTier = arg.TypeTier,
    SetSellPrice = arg.SetSellPrice
};
commerceContext.AddObject(currentTypeComponent);

In StSplitCartlineBlock we split cartline and store data in typeTierComponent which will be used in the calculate block to set the sell price of the product. Now imagine a case when a customer buys 3 products of the individual pricing tier - in this case we will have 1 cartline with quantity 3. Afterwards, the customer buys 3 products of the family pricing tier. Before this block, for a given example we would have 1 cartline quantity 6 and we want 2 cartlines with quantity 3. Number of cartlines is determined by the pricing tiers of the product chosen by a customer that are different from each other – there will be as many cartlines as there are different pricing tiers of the product chosen by a customer in the order. The logic is that we are looping to relevant cartlines and if a cartline doesn't have a component we put a component on a cartline, if a cartline has a component we check if a cartline exists with that pricing tier and deal with quantity. If a cartline with that pricing tier doesn’t exist we need to create a new cartline. Note that when the same product is added to cartline it always increases the quantity of the first cartline with that product, so we need to leave increased quantity if it’s the same pricing tier or decrease it and add it on another correspondent (adequate) cartline with that pricing tier.

StSplitCartlineBlock.cs:

public override async Task<Cart> Run(Cart cart, CommercePipelineExecutionContext context)
        {

            var typeTierComponentContext = context.CommerceContext.GetObject<TypeTierComponent>();
            //only when we add one product from specific catalog we have this component in context,
            // for others catalogs skip this block
            if (typeTierComponentContext == null)
            {
                return await Task.FromResult(cart);
            }

            var typeTierContext = typeTierComponentContext?.TypeTier;
            //get only the cartlines that has products in that specific catalog
            var cartlines = cart.Lines.Where(x=>x.ItemId.Split('|').First()
            == context.GetPolicy<CatalogNamePolicy>().Livestreams).ToList();

            if (cartlines.Count == 0)
            {
                return await Task.FromResult(cart);
            }

            var firstCartLine = cartlines[0];

            foreach (var line in cartlines)
            {
                if (!line.HasComponent<TypeTierComponent>())
                {
                    var addComponent = new TypeTierComponent
                    {
                        TypeTier = typeTierContext,
                        Quantity = line.Quantity,
                        SetSellPrice = typeTierComponentContext.SetSellPrice,
                        Currency = context.GetPolicy<GlobalEnvironmentPolicy>().DefaultCurrency
                    };
                    line.SetComponent(addComponent);
                }
                else
                {
                    var existLineWithTypeTier =
                        cartlines
                            .Where(x => x.ItemId == line.ItemId)
                            .Where(x => x.HasComponent<TypeTierComponent>())
                            .Where(x => x.GetComponent<TypeTierComponent>().TypeTier == typeTierContext)
                            .ToList();

                    var lineTypeTierComponent = line.GetComponent<TypeTierComponent>();

                    if (existLineWithTypeTier.Count > 0)
                    {
                        DealWithQuantity(cartlines, line, typeTierContext, lineTypeTierComponent);
                        continue;
                    }

                    //case for 3rd, 4th... cartline,
                    // in loop to make line in one iteration and skip others iterations.
                    var existCartLineWithTypeTier = cart.Lines
                        .Where(x => x.ItemId == line.ItemId)
                        .Where(x => x.HasComponent<TypeTierComponent>())
                        .FirstOrDefault(x => x.GetComponent<TypeTierComponent>().TypeTier == typeTierContext);

                    if (existCartLineWithTypeTier != null)
                    {
                        break;
                    }

                    CreateNewCartline(line, lineTypeTierComponent, typeTierContext,
                        typeTierComponentContext.SetSellPrice, context, cart);
                }
            }

            return await Task.FromResult(cart);
        }

        protected virtual void DealWithQuantity(
            List<CartLineComponent> cartlines,
            CartLineComponent line,
            string typeTierContext,
            TypeTierComponent lineTypeTierComponent
        )
        {

            var firstCartLine = cartlines[0];
            if (lineTypeTierComponent.TypeTier != typeTierContext)
            {
                //just continue
                return;
            }
            else if (firstCartLine.GetComponent<TypeTierComponent>().TypeTier ==
                    lineTypeTierComponent.TypeTier && lineTypeTierComponent.TypeTier == typeTierContext)
            {
                lineTypeTierComponent.Quantity = line.Quantity;
            }
            else if (lineTypeTierComponent.TypeTier == typeTierContext)
            {
                var firstCartLineTypeTierComponent = firstCartLine.GetComponent<TypeTierComponent>();
                var quantityToAdd = firstCartLine.Quantity - firstCartLineTypeTierComponent.Quantity;
                lineTypeTierComponent.Quantity += quantityToAdd;
                line.Quantity += quantityToAdd;
                firstCartLine.Quantity = firstCartLineTypeTierComponent.Quantity;
            }
        }

        protected virtual void CreateNewCartline(
            CartLineComponent line,
            TypeTierComponent lineTypeTierComponent,
            string typeTierContext,
            string contextSellPrice,
            CommercePipelineExecutionContext context,
            Cart cart
            )
        {
            IList<Component> listChildComponents = line.ChildComponents
                .Where(component => component.Name != "TypeTierComponent").ToList();

            var quantityOfNewCartLine = line.Quantity - lineTypeTierComponent.Quantity;
            line.Quantity = lineTypeTierComponent.Quantity;

            var componentToAdd = new TypeTierComponent()
            {
                TypeTier = typeTierContext,
                Quantity = quantityOfNewCartLine,
                SetSellPrice = contextSellPrice,
                Currency = context.GetPolicy<GlobalEnvironmentPolicy>().DefaultCurrency
            };
            listChildComponents.Add(componentToAdd);

            StGenerateExtraLine(cart, line, context, listChildComponents, quantityOfNewCartLine);
        }

        protected virtual void StGenerateExtraLine(
            Cart cart,
            CartLineComponent addedLine,
            CommercePipelineExecutionContext context,
            IList<Component> listChildComponents,
            decimal quantity
            )
        {
            var cartLineComponent = new CartLineComponent($"{(object) Guid.NewGuid():N}", addedLine)
            {
                ChildComponents= listChildComponents,
                Quantity = quantity

            };
            cart.Lines.Add(cartLineComponent);

            context.CommerceContext.AddModel((Model)new LineAdded()
            {
                LineId = cartLineComponent.Id,
                RolledUp = false
            });
        }

After splitting cartline and setting component to cartline we are setting the sell price for product by replacing CalculateCartLinePriceBlock with StCalculateCartLinePriceBlock in IPopulateLineItemPipeline. StCalculateCartLinePriceBlock is the same as CalculateCartLinePriceBlock with the addition to set the sell price from pricing tier component if cartline has that component. The additions are marked between comments “new” and “end new”.

StCalculateCartLinePriceBlock.cs:

public override async Task<CartLineComponent> Run(
          CartLineComponent arg,
          CommercePipelineExecutionContext context)
        {
            Condition.Requires(arg).IsNotNull(Name + ": cart line cannot be null.");

            foreach (var withSubLine in arg.WithSubLines())
            {
                if (!withSubLine.HasPolicy<PurchaseOptionMoneyPolicy>() || !withSubLine.GetPolicy<PurchaseOptionMoneyPolicy>().FixedSellPrice)
                {
                    await CalculateCartLinePrice(withSubLine, context).ConfigureAwait(false);
                }
            }
            return arg;
        }

        private async Task<bool?> CalculateCartLinePrice(
          CartLineComponent arg,
          CommercePipelineExecutionContext context)
        {
            var productArgument = ProductArgument.FromItemId(arg.ItemId);
            SellableItem sellableItem = null;

            if (productArgument.IsValid())
            {
                sellableItem = context.CommerceContext.GetEntity((Func<SellableItem, bool>)(s =>
                    s.ProductId.Equals(productArgument.ProductId, StringComparison.OrdinalIgnoreCase)));

                if (sellableItem == null)
                {
                    var simpleName = productArgument.ProductId.SimplifyEntityName();
                    sellableItem = context.CommerceContext.GetEntity((Func<SellableItem, bool>)(s =>
                        s.ProductId.Equals(simpleName, StringComparison.OrdinalIgnoreCase)));

                    if (sellableItem != null)
                    {
                        sellableItem.ProductId = simpleName;
                    }
                }
            }
            if (sellableItem == null)
            {
                var commerceContext = context.CommerceContext;
                var error = context.GetPolicy<KnownResultCodes>().Error;
                var args = new object[] { arg.ItemId };
                var defaultMessage = "Item '" + arg.ItemId + "' is not purchasable.";
                context.Abort(await commerceContext.AddMessage(error, "LineIsNotPurchasable", args, defaultMessage), context);
                context = null;
                return new bool?();
            }

            var messagesComponent = arg.GetComponent<MessagesComponent>();
            messagesComponent.Clear(context.GetPolicy<KnownMessageCodePolicy>().Pricing);

            if (sellableItem.HasComponent<MessagesComponent>())
            {
                var messages = sellableItem.GetComponent<MessagesComponent>().GetMessages(context.GetPolicy<KnownMessageCodePolicy>().Pricing);
                messagesComponent.AddMessages(messages);
            }
            arg.UnitListPrice = sellableItem.ListPrice;
            var listPriceMessage = "CartItem.ListPrice<=SellableItem.ListPrice: Price=" + arg.UnitListPrice.AsCurrency(false, null);
            var sellPriceMessage = string.Empty;
            var optionMoneyPolicy = new PurchaseOptionMoneyPolicy();

            if (sellableItem.HasPolicy<PurchaseOptionMoneyPolicy>())
            {
                optionMoneyPolicy.SellPrice = sellableItem.GetPolicy<PurchaseOptionMoneyPolicy>().SellPrice;
                sellPriceMessage = "CartItem.SellPrice<=SellableItem.SellPrice: Price=" + optionMoneyPolicy.SellPrice.AsCurrency(false, null);
            }

            PriceSnapshotComponent snapshotComponent;

            if (sellableItem.HasComponent<ItemVariationsComponent>())
            {
                var lineVariant = arg.ChildComponents.OfType<ItemVariationSelectedComponent>().FirstOrDefault();
                var itemVariationsComponent = sellableItem.GetComponent<ItemVariationsComponent>();
                ItemVariationComponent itemVariationComponent;

                if (itemVariationsComponent == null)
                {
                    itemVariationComponent = null;
                }
                else
                {
                    var childComponents = itemVariationsComponent.ChildComponents;
                    itemVariationComponent = childComponents?.OfType<ItemVariationComponent>().FirstOrDefault(v => !string.IsNullOrEmpty(v.Id)
                    && v.Id.Equals(lineVariant?.VariationId, StringComparison.OrdinalIgnoreCase));
                }

                if (itemVariationComponent != null)
                {
                    if (itemVariationComponent.HasComponent<MessagesComponent>())
                    {
                        var messages = itemVariationComponent.GetComponent<MessagesComponent>().GetMessages(context.GetPolicy<KnownMessageCodePolicy>().Pricing);
                        messagesComponent.AddMessages(messages);
                    }

                    arg.UnitListPrice = itemVariationComponent.ListPrice;
                    listPriceMessage = "CartItem.ListPrice<=SellableItem.Variation.ListPrice: Price=" + arg.UnitListPrice.AsCurrency(false, null);

                    if (itemVariationComponent.HasPolicy<PurchaseOptionMoneyPolicy>())
                    {
                        optionMoneyPolicy.SellPrice = itemVariationComponent.GetPolicy<PurchaseOptionMoneyPolicy>().SellPrice;
                        sellPriceMessage = "CartItem.SellPrice<=SellableItem.Variation.SellPrice: Price=" + optionMoneyPolicy.SellPrice.AsCurrency(false, null);
                    }
                }
                snapshotComponent = itemVariationComponent?.ChildComponents.OfType<PriceSnapshotComponent>().FirstOrDefault();
            }
            else
            {
                snapshotComponent = sellableItem.EntityComponents.OfType<PriceSnapshotComponent>().FirstOrDefault();
            }

            var currentCurrency = context.CommerceContext.CurrentCurrency();

            var priceTier = snapshotComponent?.Tiers.OrderByDescending(t => t.Quantity).FirstOrDefault(t =>
                t.Currency.Equals(currentCurrency, StringComparison.OrdinalIgnoreCase) && t.Quantity <= arg.Quantity);

            //new
            var isSetSellPrice = false;

            if (arg.HasComponent<TypeTierComponent>())
            {
                var typeTierComponent = arg.GetComponent<TypeTierComponent>();
                optionMoneyPolicy.SellPrice = new Money(typeTierComponent.Currency, decimal.Parse(typeTierComponent.SetSellPrice));
                sellPriceMessage =
                                $"CartItem.SellPrice<=PriceCard.ActiveSnapshot: TypeTier={typeTierComponent.TypeTier}" +
                                $"|Price={optionMoneyPolicy.SellPrice.AsCurrency(false, null)}";
                isSetSellPrice = true;
            }

            if (!isSetSellPrice && priceTier != null)
            {
                optionMoneyPolicy.SellPrice = new Money(priceTier.Currency, priceTier.Price);
                sellPriceMessage =
                    $"CartItem.SellPrice<=PriceCard.ActiveSnapshot: Price={optionMoneyPolicy.SellPrice.AsCurrency(false, null)}|Qty={priceTier.Quantity}";
            }
            //end new

            arg.Policies.Remove(arg.Policies.OfType<PurchaseOptionMoneyPolicy>().FirstOrDefault());

            if (optionMoneyPolicy.SellPrice == null)
            {
                return false;
            }

            arg.SetPolicy(optionMoneyPolicy);

            if (!string.IsNullOrEmpty(sellPriceMessage))
            {
                messagesComponent.AddMessage(context.GetPolicy<KnownMessageCodePolicy>().Pricing, sellPriceMessage);
            }

            if (!string.IsNullOrEmpty(listPriceMessage))
            {
                messagesComponent.AddMessage(context.GetPolicy<KnownMessageCodePolicy>().Pricing, listPriceMessage);
            }

            return true;
        }
    }
Thank You for Reading
Share This Page