Building a modular system for e-products
21st of January, 2017 0 comments

Building a modular system for e-products

The concept of an e-product in Kentico is a digital download, such as an audio file or PDF document. Quite a few ecommerce sites sell virtual products that are not static documents, but special features, dynamically generated PDFs, and other services and digital products.

These special types of products are not supported by Kentico out-of-the-box, and require custom development.

The OrderIsPaid event

The common approach in these scenarios is to subscribe to the EcommerceEvents.OrderPaid.Execute event. Each time an order is marked as "paid" (either manually through the Orders application, or as a result of an actual payment), the event is triggered. The signature of the event handler contains a OrderPaidEventArgs parameter, with an OrderInfo property.

Let's say that a specific action should be performed, once a particular product is purchased. Since we are provided with a OrderInfo object from the event handler, the following would need to happen:

  1. OrderItemInfo objects are retrieved from the database, that relate to the OrderInfo object
  2. The order items are checked, to determine if the correct product was purchased
  3. The custom action is performed
  4. Optionally, the order item is marked as "processed", to prevent the custom action from being performed again in any subsequent changes to the order

This is quite inflexible, and takes a lot of time to write, test and debug.

Simplifying and modularising

In our attempt to solve this problem, we will look into developing a way of representing each product in its own class. The custom actions are then defined in the class, to be processed after the order is marked as paid.

We will also ensure that there is minimal "plumbing" work required to plug in the e-product class, after its creation.

In the end, creating a new custom e-product should simply be done by creating a class that looks like this:

[assembly: RegisterCustomEProduct("ProductSKUNumber", typeof(MyProduct))]
namespace MyCustomEProducts
{
    public class MyProduct : ICustomEProduct
    {
        public void ProcessPaidProduct(ShoppingCartItemInfo cartItem) {
            // Things that happen when the product is purchased
        }
    }
}

Setting up the module

I tend to follow the practice of creating a new project in Visual Studio for each module, and configuring the initialisation. We are creating a 'code only' module, so no work needs to be done in the "Modules" application in Kentico.

  • Add a new class library project to your solution. I named mine CustomEProducts
  • Add references to the following assemblies:
CMS.Base
CMS.Core
CMS.DataEngine
CMS.Ecommerce
CMS.EventLog
CMS.Helpers
  • Open the AssemblyInfo.cs file and add the AssemblyDiscoverable attribute
[assembly: AssemblyDiscoverable()]
  • Create a new class for the module. This will allow us to attach events when the application starts.
[assembly: RegisterModule(typeof(CustomEProductsModule))]
namespace CustomEProducts
{
    public class CustomEProductsModule : Module
    {
        public CustomEProductsModule() : base("CustomEProducts")
        {
        }
    }
}

Creating the e-product interface

All custom e-product classes must implement an interface, to ensure they contain a method that will be executed when the product is purchased.

using CMS.Ecommerce;

namespace CustomEProducts
{
    public interface ICustomEProduct
    {
        void ProcessPaidProduct(ShoppingCartItemInfo cartItem);
    }
}

The e-product manager class

This class (let's call it CustomEProductManager) is the 'brains' of the operation, with the following responsibilities:

  • Holds a collection of all e-products registered in the system
  • Allows a new e-product to be registered (and added to the collection)
  • Handles the event of an order being set as "paid". It then executes the ProcessPaidProduct method for the corresponding e-product, if one exists.

Let's start by creating a collection of products - a dictionary. The string key of the dictionary is the SKUNumber of the product, and the ICustomEProduct value will be the class instance that represents the SKU.

private static readonly Dictionary<string, ICustomEProduct> _EProducts = new Dictionary<string, ICustomEProduct>();

Now we need a way to add products to the dictionary. We will create a 'registration' method, which will add the product to the collection. The collection created above is private, and this method will expose a way of registering products.

public static void RegisterEProduct(string skuNumber, ICustomEProduct customEProduct)
{
    // Ensure that the parameters are provided
    if (skuNumber == null)
    {
        throw new ArgumentNullException(nameof(skuNumber));
    }

    if(customEProduct == null)
    {
        throw new ArgumentNullException(nameof(customEProduct));
    }

    _EProducts[skuNumber] = customEProduct;
}

Next, we create a method that triggers the ProcessPaidProduct method for each class instance that implements the ICustomEProduct interface, and corresponds to a product in the shopping cart.

The method works as follows:

  • A ShoppingCartInfo object is recreated from the order
  • An XML structure of processed items is retrieved from the OrderCustomData property of the order, and loaded into a ContainerCustomData object for easy access
  • Cart items are iterated, skipping any items that have already been processed (determined by the SKUNumber being present in OrderCustomData)
  • SKUNumber is compared against the collection of registered e-products (_EProducts), to determine if a class instance is registered for the particular product
  • The ProcessPaidProduct method is invoked on the class instance that corresponds to the product
  • The product is marked as 'processed' by being added to the ContainerCustomData object
  • The OrderCustomData property is updated with an XML representation of the ContainerCustomData object
private static void ProcessCustomEProducts(OrderInfo oi)
{
    // Get shopping cart items
    var sci = ShoppingCartInfoProvider.GetShoppingCartInfoFromOrder(oi.OrderID);
    if (sci == null)
        return;

    var customProcessingData = oi.OrderCustomData.GetValue("CustomProcessing") as string;

    var existingValues = new ContainerCustomData();
    existingValues.LoadData(customProcessingData);

    foreach (var i in sci.CartItems.Where(x => existingValues[x.SKU.SKUNumber] == null))
    {
        if (!_EProducts.ContainsKey(i.SKU.SKUNumber))
            continue;

        _EProducts[i.SKU.SKUNumber].ProcessPaidProduct(i);

        existingValues[i.SKU.SKUNumber] = true;
    }

    oi.OrderCustomData.SetValue("CustomProcessing", existingValues.GetData());
    oi.Update();
}

The method can now be invoked for the EcommerceEvents.OrderPaid global event. We will create an intermediary method, as a link between the event handler and the ProcessCustomEProducts method.

public static void OrderPaid(object sender, OrderPaidEventArgs e)
{
    ProcessCustomEProducts(e.Order);
}

The event handler is attached in the CustomEProductsModule class.

public class CustomEProductsModule : Module
{
    public CustomEProductsModule() : base("CustomEProducts")
    {
    }

    protected override void OnInit()
    {
        EcommerceEvents.OrderPaid.Execute += CustomEProductManager.OrderPaid;
    }
}

Registering products on application start

On application startup, the ProcessCustomEProducts method is linked up to handle the EcommerceEvents.OrderPaid event, however, the ICustomEProduct implementations are not present in _EProducts. To keep things simple, we will create a class attribute, that will automatically add the class to the _EProducts collection, by calling the RegisterEProduct method.

The trick is in the IPreInitAttribute interface that our attribute implements. Classes that implement this interface are discovered by Kentico during the application start phase, and the PreInit method is invoked. We can utilise this to call the RegisterEProduct method.

namespace CustomEProducts
{
    [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
    public class RegisterCustomEProductAttribute : Attribute, IPreInitAttribute
    {
        public Type MarkedType { get; private set; }
        private readonly string _skuNumber;

        public RegisterCustomEProductAttribute(string skuNumber, Type customEProduct)
        {
            if (skuNumber == null)
                throw new ArgumentNullException(nameof(skuNumber));

            if (customEProduct == null)
                throw new ArgumentNullException(nameof(customEProduct));

            if (!typeof(ICustomEProduct).IsAssignableFrom(customEProduct))
                throw new ArgumentException("Provided type does not implement ICustomEProduct interface.", nameof(customEProduct));

            _skuNumber = skuNumber;
            MarkedType = customEProduct;
        }

        public void PreInit()
        {
            CustomEProductManager.RegisterEProduct(_skuNumber, (ICustomEProduct)Activator.CreateInstance(MarkedType));
        }
    }
}

Creating e-products

Now that the main classes are completed, we can finally look at creating actual e-products.

The process is:

  1. Create a class that implements the ICustomEProduct interface
  2. Write the logic in the ProcessPaidProduct method
  3. Register the class with the RegisterCustomEProduct attribute

Our sample product will do nothing more than inserting an entry in the event log.

[assembly: RegisterCustomEProduct("MySpecialProduct", typeof(MySpecialProduct))]
namespace MyCustomEProducts
{
    public class MySpecialProduct : ICustomEProduct
    {
        public void ProcessPaidProduct(ShoppingCartItemInfo cartItem) {
            EventLogProvider.LogInformation("CustomEProducts", "Purchased", string.Format("Someone has purchased {0} unit(s) of the product!", cartItem.CartItemUnits));
        }
    }
}

Each time a product with the "MySpecialProduct" SKUNumber is purchased, an event will be logged in the event log.

Limitations

Some things that can be improved in this example:

  • Separation of concerns - some classes do more than they should be doing
  • One e-product class per SKU - as we are using a dictionary to store a list of products, each product can be represented by a single class only
  • No 'payment reversed' event - probably the biggest limitation, however it can be fixed using component events
  • Messy nature of using a ContainerCustomData object inside OrderCustomData - OrderCustomData is also of the ContainerCustomData type. They are nested for the sake of simplicity, and it was a way for me to avoid dealing with XML serialisation/deserialisation in this example

Source code

As the code in this post is broken up into pieces, it might be a bit tedious to follow and understand. For this reason, I have posted the source code as a Gist.

Enjoy!

Written by Kristian Bortnik


Tags

Comments