Special Promotions and Offers for Customer Segments in EPiServer Commerce
Based on EPiServer CMS 7.1, Commerce 1 R3, and the Enoteca Templates
Shannon Gray recently wrote an excellent blog on how to “Do A Lot More With EPiServer Commerce Promotions With a Little Code”. Inspired by this blog, as well as a similar presentation Shannon gave at the EPiServer 2013 North American Partner Summit, I thought I’d use some of his tips and code to add the ability to use Visitor Groups in Commerce promotions in my demo environment. The end result is the ability to provide special promotions and offers to customers segments (via Visitor Groups). Powerful!
I chose to take what appeared to be the best possible path in Shannon’s blog which is covered in his “Extending the Promotion Engine Further” section. This explains how to create a new Promotion Provider. I suggest you read his blog as a starting point to understand this provider and some of the other concepts applied here. In summary I followed his summary steps, copied the provider, and added the logic I wanted. The outcome was fantastic.
The End Result
The end result, again, is the ability to set up customer segments via EPiServer Visitor Groups…
and target them with special promotions and offers in product listings,…
on product detail pages,…
and on the cart summary page.
How to Build It
In this section I’ll start from the beginning and take you through the whole process one step at a time. I’ll also provide the project and code in the “How to Get the Code” section at the tail of this blog.
Set up the Meta Field and Class
- Navigate the Commerce Manager.
- Create a new Meta Field to be used later to store a list of Visitor Groups the current visitor is a member of. Navigate to the Administration~Order System~Meta Fields and add a new Meta Field similar to the following.
- Select the “OK” button when complete to save.
- We’ll be working with applying a discount to a line item based on membership in a Visitor Group. Now that you’ve created the Meta Field add it to the relevant Meta Class. Navigate to Administration~Order System~Meta Classes.
- Select “Line Item” for the “Element” setting.
- Scroll to the bottom and select the check box next to the “Visitor Group” Meta Field to add it to the “LineItem” Meta Class.
- Select the “OK” button when complete to save.
- After adding the new Line Item Meta Fields reset IIS to ensure the new field(s) appear in Commerce Manager.
Set up the Promotion Provider
- Create a new web project in the “modules” folder of the Enoteca project. I used the ASP.NET Empty Web Application.
- Add the assemblies pictured below to your project references.
- Add one class file (.cs) and name it “CustomPromotionEntryPopulate”.
- I recommend that you take the easiest route and copy my provider code from the code snippet below and paste it into your CustomPromotionEntryPopulate.cs file replacing all previously existing code.
1: using EPiServer.Personalization.VisitorGroups;
2: using EPiServer.Security;
3: using EPiServer.ServiceLocation;
4: using Mediachase.Commerce;
5: using Mediachase.Commerce.Catalog;
6: using Mediachase.Commerce.Catalog.Objects;
7: using Mediachase.Commerce.Marketing;
8: using Mediachase.Commerce.Orders;
9: using Mediachase.Commerce.Pricing;
10: using Mediachase.MetaDataPlus.Configurator;
11: using System;
12: using System.Collections.Generic;
13: using System.Security.Principal;
14:
15: namespace EPiServer.Commerce.Addons.Promotions
16: {
17: public class CustomPromotionEntryPopulate : IPromotionEntryPopulate
18: {
19: // Methods
20: public void Populate(PromotionEntry entry, LineItem lineItem)
21: {
22: entry.Quantity = lineItem.Quantity;
23: entry.Owner = lineItem;
24: entry["LineItemId"] = lineItem.LineItemId;
25: entry["ShippingAddressId"] = lineItem.ShippingAddressId;
26: entry["ExtendedPrice"] = lineItem.ExtendedPrice;
27: entry["MinQuantity"] = lineItem.MinQuantity;
28: entry["MaxQuantity"] = lineItem.MaxQuantity;
29: entry["LineItemDiscountAmount"] = lineItem.LineItemDiscountAmount;
30: entry["OrderLevelDiscountAmount"] = lineItem.OrderLevelDiscountAmount;
31: entry["ShippingMethodName"] = lineItem.ShippingMethodName ?? string.Empty;
32: entry["ExtendedPrice"] = lineItem.ExtendedPrice;
33: entry["Description"] = lineItem.Description ?? string.Empty;
34: entry["Status"] = lineItem.Status ?? string.Empty;
35: entry["DisplayName"] = lineItem.DisplayName ?? string.Empty;
36: entry["AllowBackordersAndPreorders"] = lineItem.AllowBackordersAndPreorders;
37: entry["InStockQuantity"] = lineItem.InStockQuantity;
38: entry["PreorderQuantity"] = lineItem.PreorderQuantity;
39: entry["BackorderQuantity"] = lineItem.BackorderQuantity;
40: entry["InventoryStatus"] = lineItem.InventoryStatus;
41: foreach (MetaField field in lineItem.MetaClass.MetaFields)
42: {
43: entry[field.Name] = lineItem[field];
44: }
45:
46: PopulateVisitorGroupEntry(ref entry);
47: }
48:
49: public void PopulateVisitorGroupEntry(ref PromotionEntry entry)
50: {
51: // Retrieve the last time we made this check and the principal user at the time from the cache
52: IPrincipal lastPrincipal = null;
53: double lastTime = 0;
54: try
55: {
56: lastPrincipal = CacheManager.Get("promoLastPrincipal") as IPrincipal;
57: lastTime = (double)CacheManager.Get("promoLastTime");
58: }
59: catch (NullReferenceException)
60: {
61: // Catch any nulls on the double case above. Then continue...
62: }
63:
64: // KLUDGE: For demo purposes only. For performance, only re-check VG's if more than 5 seconds have passed or
65: // if the principal has changed.
66: if (((DateTime.Now.TimeOfDay.TotalSeconds - lastTime) >= 5) || (!PrincipalInfo.CurrentPrincipal.Equals(lastPrincipal)))
67: {
68: // Get all the VG repository and all roles available within it
69: VisitorGroupRoleRepository vgRepository = VisitorGroupRole.GetRepository();
70: IEnumerable<string> vgRoles = vgRepository.GetAllRoles();
71: VisitorGroupHelper vgHelper = new VisitorGroupHelper();
72: string tempVisitorRoles = "";
73:
74: // Loop through all VG's to see if the current visitor is a member of any
75: foreach(string role in vgRoles)
76: {
77: // Is the visitor in the role?
78: if (vgHelper.IsPrincipalInGroup(PrincipalInfo.CurrentPrincipal, role))
79: {
80: // If there is a match add the role to the temp string with a comma delimiter. This will allow for creating of
81: // an expression in the Commerce Manager Promotions area for any number of visitor groups. For example, you
82: // can specify if the Visitor Group "Contains" the "US Visitor" VG, give 10% off.
83: tempVisitorRoles = tempVisitorRoles + role + ",";
84: }
85: }
86:
87: // Update the entry with all roles the visitor is a member of
88: entry["VisitorGroup"] = tempVisitorRoles;
89: }
90:
91: // Cache the last time we made this check and the principal user at the time
92: CacheManager.Add("promoLastTime", DateTime.Now.TimeOfDay.TotalSeconds);
93: CacheManager.Add("promoLastPrincipal", PrincipalInfo.CurrentPrincipal);
94: }
95:
96: public void Populate(ref PromotionEntry promotionEntry, object val)
97: {
98: throw new NotSupportedException("This method is obsolete.");
99: }
100:
101: public void Populate(PromotionEntry entry, Entry catalogEntry, MarketId marketId, Currency currency)
102: {
103: entry.Quantity = 1M;
104: entry.Owner = catalogEntry;
105: entry["Id"] = catalogEntry.ID;
106: if (catalogEntry.ItemAttributes != null)
107: {
108: entry["MinQuantity"] = catalogEntry.ItemAttributes.MinQuantity;
109: entry["MaxQuantity"] = catalogEntry.ItemAttributes.MaxQuantity;
110: if (catalogEntry.ItemAttributes.Attribute != null)
111: {
112: foreach (ItemAttribute attribute in catalogEntry.ItemAttributes.Attribute)
113: {
114: if ((attribute.Value != null) && (attribute.Value.Length > 0))
115: {
116: entry[attribute.Name] = attribute.Value[0];
117: }
118: }
119: }
120: }
121: if (catalogEntry.Inventory != null)
122: {
123: entry["AllowBackordersAndPreorders"] = !catalogEntry.Inventory.AllowBackorder ? ((object) 0) : ((object) catalogEntry.Inventory.AllowPreorder);
124: entry["InStockQuantity"] = catalogEntry.Inventory.InStockQuantity;
125: entry["PreorderQuantity"] = catalogEntry.Inventory.PreorderQuantity;
126: entry["BackorderQuantity"] = catalogEntry.Inventory.BackorderQuantity;
127: entry["InventoryStatus"] = catalogEntry.Inventory.InventoryStatus;
128: }
129: IPriceValue value2 = ServiceLocator.Current.GetInstance<IPriceService>().GetDefaultPrice(marketId, FrameworkContext.Current.CurrentDateTime, new CatalogKey(catalogEntry), currency);
130: if (value2 != null)
131: {
132: entry["ExtendedPrice"] = value2.UnitPrice.Amount;
133: }
134:
135: PopulateVisitorGroupEntry(ref entry);
136: }
137:
138: public void PopulateCustom(PromotionEntry promotionEntry, object obj)
139: {
140: throw new NotSupportedException();
141: }
142: }
143: }
- The core of my changes from Shannon’s provider are primarily contained in one method, PopulateVisitorGroupEntry(), and the important code is as follows. I’ve added some comments in an attempt to maximize readability. It basically does the following:
- Gets the Visitor Group repository
- Gets all the Visitor Groups from that repository
- Iterates through all the Visitor Groups and checks if the current principal is a member of that group
- If so, it adds them to a comma delimited string (you can deliver a more robust solution in your code as needed)
- Once done iterating it adds the string to the promotion entry so that the promotion can validate against this string
1: // Get all the VG repository and all roles available within it
2: VisitorGroupRoleRepository vgRepository = VisitorGroupRole.GetRepository();
3: IEnumerable<string> vgRoles = vgRepository.GetAllRoles();
4: VisitorGroupHelper vgHelper = new VisitorGroupHelper();
5: string tempVisitorRoles = "";
6:
7: // Loop through all VG's to see if the current visitor is a member of any
8: foreach(string role in vgRoles)
9: {
10: // Is the visitor in the role?
11: if (vgHelper.IsPrincipalInGroup(PrincipalInfo.CurrentPrincipal, role))
12: {
13: // If there is a match add the role to the temp string with a comma delimiter. This will allow for creating of
14: // an expression in the Commerce Manager Promotions area for any number of visitor groups. For example, you
15: // can specify if the Visitor Group "Contains" the "US Visitor" VG, give 10% off.
16: tempVisitorRoles = tempVisitorRoles + role + ",";
17: }
18: }
19:
20: // Update the entry with all roles the visitor is a member of
21: entry["VisitorGroup"] = tempVisitorRoles;
- PopulateVisitorGroupEntry() is called from the two core Populate() methods in the provider.
- public void Populate(PromotionEntry entry, LineItem lineItem) – Called when calculating the price for the entire shopping cart, allowing the visitor to see the discount in their cart/basket page.
- public void Populate(PromotionEntry entry, Entry catalogEntry, MarketId marketId, Currency currency) - Called when calculating the price of an individual SKU, allowing the visitor to see the discount in a product listing (when the listing includes price) or the product page.
1: using EPiServer.Personalization.VisitorGroups;
2: using EPiServer.Security;
3: using EPiServer.ServiceLocation;
4: using Mediachase.Commerce;
5: using Mediachase.Commerce.Catalog;
6: using Mediachase.Commerce.Catalog.Objects;
7: using Mediachase.Commerce.Marketing;
8: using Mediachase.Commerce.Orders;
9: using Mediachase.Commerce.Pricing;
10: using Mediachase.MetaDataPlus.Configurator;
11: using System;
12: using System.Collections.Generic;
13: using System.Security.Principal;
14:
15: namespace EPiServer.Commerce.Addons.Promotions
16: {
17: public class CustomPromotionEntryPopulate : IPromotionEntryPopulate
18: {
19: // Methods
20: public void Populate(PromotionEntry entry, LineItem lineItem)
21: {
22: entry.Quantity = lineItem.Quantity;
23: entry.Owner = lineItem;
24: entry["LineItemId"] = lineItem.LineItemId;
25: entry["ShippingAddressId"] = lineItem.ShippingAddressId;
26: entry["ExtendedPrice"] = lineItem.ExtendedPrice;
27: entry["MinQuantity"] = lineItem.MinQuantity;
28: entry["MaxQuantity"] = lineItem.MaxQuantity;
29: entry["LineItemDiscountAmount"] = lineItem.LineItemDiscountAmount;
30: entry["OrderLevelDiscountAmount"] = lineItem.OrderLevelDiscountAmount;
31: entry["ShippingMethodName"] = lineItem.ShippingMethodName ?? string.Empty;
32: entry["ExtendedPrice"] = lineItem.ExtendedPrice;
33: entry["Description"] = lineItem.Description ?? string.Empty;
34: entry["Status"] = lineItem.Status ?? string.Empty;
35: entry["DisplayName"] = lineItem.DisplayName ?? string.Empty;
36: entry["AllowBackordersAndPreorders"] = lineItem.AllowBackordersAndPreorders;
37: entry["InStockQuantity"] = lineItem.InStockQuantity;
38: entry["PreorderQuantity"] = lineItem.PreorderQuantity;
39: entry["BackorderQuantity"] = lineItem.BackorderQuantity;
40: entry["InventoryStatus"] = lineItem.InventoryStatus;
41: foreach (MetaField field in lineItem.MetaClass.MetaFields)
42: {
43: entry[field.Name] = lineItem[field];
44: }
45:
46: PopulateVisitorGroupEntry(ref entry);
47: }
48:
49: public void PopulateVisitorGroupEntry(ref PromotionEntry entry)
50: {
51: // KLUDGE: For demo purposes only. Retrieve the last time we made this check and the principal user at the
52: // time from the cache.
53: IPrincipal lastPrincipal = null;
54: double lastTime = 0;
55: try
56: {
57: lastPrincipal = CacheManager.Get("promoLastPrincipal") as IPrincipal;
58: lastTime = (double)CacheManager.Get("promoLastTime");
59: }
60: catch (NullReferenceException)
61: {
62: // Catch any nulls on the double case above. Then continue...
63: }
64:
65: // KLUDGE: For demo purposes only. For performance, only re-check VG's if more than 5 seconds have passed or
66: // if the principal has changed.
67: if (((DateTime.Now.TimeOfDay.TotalSeconds - lastTime) >= 5) || (!PrincipalInfo.CurrentPrincipal.Equals(lastPrincipal)))
68: {
69: // Get all the VG repository and all roles available within it
70: VisitorGroupRoleRepository vgRepository = VisitorGroupRole.GetRepository();
71: IEnumerable<string> vgRoles = vgRepository.GetAllRoles();
72: VisitorGroupHelper vgHelper = new VisitorGroupHelper();
73: string tempVisitorRoles = "";
74:
75: // Loop through all VG's to see if the current visitor is a member of any
76: foreach(string role in vgRoles)
77: {
78: // Is the visitor in the role?
79: if (vgHelper.IsPrincipalInGroup(PrincipalInfo.CurrentPrincipal, role))
80: {
81: // If there is a match add the role to the temp string with a comma delimiter. This will allow for creating of
82: // an expression in the Commerce Manager Promotions area for any number of visitor groups. For example, you
83: // can specify if the Visitor Group "Contains" the "US Visitor" VG, give 10% off.
84: tempVisitorRoles = tempVisitorRoles + role + ",";
85: }
86: }
87:
88: // Update the entry with all roles the visitor is a member of
89: entry["VisitorGroup"] = tempVisitorRoles;
90: }
91:
92: // KLUDGE: For demo purposes only. Cache the last time we made this check and the principal user at the time.
93: CacheManager.Add("promoLastTime", DateTime.Now.TimeOfDay.TotalSeconds);
94: CacheManager.Add("promoLastPrincipal", PrincipalInfo.CurrentPrincipal);
95: }
96:
97: public void Populate(ref PromotionEntry promotionEntry, object val)
98: {
99: throw new NotSupportedException("This method is obsolete.");
100: }
101:
102: public void Populate(PromotionEntry entry, Entry catalogEntry, MarketId marketId, Currency currency)
103: {
104: entry.Quantity = 1M;
105: entry.Owner = catalogEntry;
106: entry["Id"] = catalogEntry.ID;
107: if (catalogEntry.ItemAttributes != null)
108: {
109: entry["MinQuantity"] = catalogEntry.ItemAttributes.MinQuantity;
110: entry["MaxQuantity"] = catalogEntry.ItemAttributes.MaxQuantity;
111: if (catalogEntry.ItemAttributes.Attribute != null)
112: {
113: foreach (ItemAttribute attribute in catalogEntry.ItemAttributes.Attribute)
114: {
115: if ((attribute.Value != null) && (attribute.Value.Length > 0))
116: {
117: entry[attribute.Name] = attribute.Value[0];
118: }
119: }
120: }
121: }
122: if (catalogEntry.Inventory != null)
123: {
124: entry["AllowBackordersAndPreorders"] = !catalogEntry.Inventory.AllowBackorder ? ((object) 0) : ((object) catalogEntry.Inventory.AllowPreorder);
125: entry["InStockQuantity"] = catalogEntry.Inventory.InStockQuantity;
126: entry["PreorderQuantity"] = catalogEntry.Inventory.PreorderQuantity;
127: entry["BackorderQuantity"] = catalogEntry.Inventory.BackorderQuantity;
128: entry["InventoryStatus"] = catalogEntry.Inventory.InventoryStatus;
129: }
130: IPriceValue value2 = ServiceLocator.Current.GetInstance<IPriceService>().GetDefaultPrice(marketId, FrameworkContext.Current.CurrentDateTime, new CatalogKey(catalogEntry), currency);
131: if (value2 != null)
132: {
133: entry["ExtendedPrice"] = value2.UnitPrice.Amount;
134: }
135:
136: PopulateVisitorGroupEntry(ref entry);
137: }
138:
139: public void PopulateCustom(PromotionEntry promotionEntry, object obj)
140: {
141: throw new NotSupportedException();
142: }
143: }
144: }
- The provider is called a number of times to check promotions, in some cases multiple times. For example, on the cart page it will be called three times. To enhance performance I put in a bit of a kludge to ensure this isn’t called too many times repeatedly. You’ll see that the code checks the last time the code was executed and the last principal before executing the code to validate the current principals Visitor Groups again. Essentially it says if less than five seconds have passed and the principal is the same do not check again. THIS IS INTENDED FOR DEMO PERPOSES ONLY! In the real world this would not be a likely best practice. Since any visitor behavior on the site could cause them to be added to or removed from a particular Visitor Group you may choose cache the Visitor Groups for a user and hook into some related event using this to invalidate that cache rather than using the code I just described. This solution should suffice for my demo environment however.
- Change the project settings via your preferred method to ensure the DLL is found when the site runs. I simply changed the Output directory to the site bin folder. You could copy the DLL in the post build event, put in modulesbin, etc. depending on your preference. But you already knew this!
- Build the project and validate the DLL is in the expected directory.
Change the Default Promotion Provider
- Navigate to the site root “Configs” folder.
- Open the ecf.marketing.config file.
- Remove or comment out the existing PromotionEntryPopulateFunctionType line.
- Add a new one similar to the one in my code below. The first part of the “name” configuration should be the full class name including namespace, in my case: EPiServer.Commerce.Addons.Promotions.CustomPromotionEntryPopulate. The second part should be the DLL name, in my case: EPiServer.Commerce.Addons.Promotions.
1: <?xml version="1.0"?>2: <Marketing>
3: <Connection connectionStringName="EcfSqlConnection" />4: <Cache enabled="true" promotionTimeout="0:1:0" campaignTimeout="0:1:0" policyTimeout="0:1:0" segmentTimeout="0:1:0" expressionTimeout="0:1:0" />5: <MappedTypes>
6: <ExpressionValidatorType name="Mediachase.Commerce.Marketing.Validators.RulesExprValidator,Mediachase.Commerce.Marketing.Validators" />7: <!-- <PromotionEntryPopulateFunctionType name="Mediachase.Commerce.Marketing.Validators.PromotionEntryPopulate,Mediachase.Commerce.Marketing.Validators" /> -->8: <PromotionEntryPopulateFunctionType name="EPiServer.Commerce.Addons.Promotions.CustomPromotionEntryPopulate, EPiServer.Commerce.Addons.Promotions" />9: </MappedTypes>
10: <Roles />
11: </Marketing>
- Save the file.
Create a Visitor Group
- Below is the definition of the Visitor Group I used for my testing, a “Frequent Orderer” segment for anyone who has ordered five times in a thirty day period. Creating a Visitor Group to segment your visitor is beyond the scope of this blog post. Please refer to EPiServer Web Help or User Manuals for more info on this process.
Set up a Promotion Targeting a Visitor Group
- You should now be able to set up promotions targeting Visitor Groups.
- Navigate the Commerce Manager.
- Navigate to Marketing~Promotions.
- Select the “Promotions” node. Note, this assumes you have set up any necessary Campaigns or Customer Segments you might wish to use in the Commerce Manager. Creating Promotions, Campaigns, and Customer Segments is beyond the scope of this blog post. Please refer to the Web Help for more info on this process. I will walk you through the related configuration for targeting a Visitor Group.
- Create a “Catalog Entry: Build Your Own Discount” promotion. Follow the relevant steps noted in the previously referenced Web Help.
- The important part as it relates to this blog is the “Purchase Condition and Reward” section. When you select the Purchase Condition you should now see “Visitor Group” listed as an option. Select this.
- The visitor could be part of multiple Visitor Groups and if you recall we used a comma separated string to store these. So, for the operator in the conditions use “Contains”.
- In the “Text” portion add the name, or names using an “Or”, of the Visitor Groups you wish to target. In my example I named the Visitor Group “Frequent Order” so this is what I used here.
- Finish configuring the promotion and select the “OK” button to save. My example promotions entire definition can be seen below.
Shipping and Order Promotions
The same code already added in the provider also enables you to apply similar discounts for shipping and order promotions.
Shipping
- Create a “Shipping: Build Your Own Discount” promotion.
- The important part as it relates to this blog is the “Purchase Condition and Reward” section. In the first statement select “Shipment.LineItems”.
- Set the conditional statement to Any Equals True.
- Add an additional condition where Visitor Group Contains <your visitor group name>. In my case I used the “Frequent Orderer” Visitor Group. Remember that we use “Contains” because the visitor could be part of multiple Visitor Groups.
- My example promotions entire definition can be seen below.
- You can see the resulting shipping discount below. Note the “Shipping Cost” is zero.
Orders
- Create a “Order: Build Your Own Discount” promotion.
- The important part as it relates to this blog is the “Purchase Condition and Reward” section. In the first statement select “OrderForm.LineItems”.
- Set the conditional statement to Any Equals True.
- Add an additional condition where Visitor Group Contains <your visitor group name>. In my case I used the “Frequent Orderer” Visitor Group. Remember that we use “Contains” because the visitor could be part of multiple Visitor Groups.
- My example promotions entire definition can be seen below.
- You can see the resulting order discount below in the “Order Discount”.
How to Get the Code
If you wish to simply utilize my project and code you can download a zip file containing it below:
Based on this you can skip the “Set up the Promotion Provider” section but follow the others:
- Set up the Meta Field and Class
- Change the Default Promotion Provider
- Create a Visitor Group
- Set up a Promotion Targeting a Visitor Group
Assuming all went as expected you should now be able to target Visitor Groups with the relevant promotion types. The result should be as shown in the “The End Result” section near the top of this blog.
Special thanks to Shannon Gray for his excellent blog and his assistance in getting this all set up. Enjoy!
- This is intended as a starting point for you to customize for your project needs. The code is provided “as is” without warranty or guarantee of operation. Use at your own risk.
Nice!
can you help? i can only access visitor groups when i log in as Administrator of our AD server. if i logged using my account which is part of the site's admin, i cant access the visitor groups
Be aware that the "CustomPromotionEntryPopulate" will also be called from inside Commerce Manager, when orders are processed by webshop staff.
In this example, visitor groups are reevaluated in the context of the current user.
So when webshop staff logs in and processes the order, visitor group information is no longer correct, and discounts may disappear.
As an alternative, we added a "VisitorGroup" metafield to OrderGroup. This is updated by workflows in a Cart-activity.
When the cart is converted to a PurchaseOrder, the "VisitorGroup" metafield is "frozen".
Our "CustomPromotionEntryPopulate" uses the OrderGroup metafield to populate the PromotionEntry, so we avoid reevaluating the visitor groups when orders are processed in Commerce Manager.