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 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:
OrderItemInfo
objects are retrieved from the database, that relate to the OrderInfo
objectThis is quite inflexible, and takes a lot of time to write, test and debug.
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
}
}
}
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.
CustomEProducts
CMS.Base
CMS.Core
CMS.DataEngine
CMS.Ecommerce
CMS.EventLog
CMS.Helpers
AssemblyInfo.cs
file and add the AssemblyDiscoverable
attribute[assembly: AssemblyDiscoverable()]
[assembly: RegisterModule(typeof(CustomEProductsModule))]
namespace CustomEProducts
{
public class CustomEProductsModule : Module
{
public CustomEProductsModule() : base("CustomEProducts")
{
}
}
}
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);
}
}
This class (let's call it CustomEProductManager
) is the 'brains' of the operation, with the following responsibilities:
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:
ShoppingCartInfo
object is recreated from the orderOrderCustomData
property of the order, and loaded into a ContainerCustomData
object for easy accessOrderCustomData
)_EProducts
), to determine if a class instance is registered for the particular productProcessPaidProduct
method is invoked on the class instance that corresponds to the productContainerCustomData
objectOrderCustomData
property is updated with an XML representation of the ContainerCustomData
objectprivate 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;
}
}
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));
}
}
}
Now that the main classes are completed, we can finally look at creating actual e-products.
The process is:
ICustomEProduct
interfaceProcessPaidProduct
methodRegisterCustomEProduct
attributeOur 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.
Some things that can be improved in this example:
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 exampleAs 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!