To be fair from the start: Salesforce has been strongly enhancing their Commerce Cloud pricing features recently, like Conditional Promotion Rules, so I’d actually be happy to see this post deprecating in a couple of years, being replaced by SF Standard.
But as long as standard functionalities are only capable of filtering by product, category and price, a custom solution is needed to grant quantity discounts, be it a price rebate, or even free items (“buy two and get one free”). And since you probably don’t want to write code with every new discount scenario or even condition, let’s put the whole thing into a custom metadata-driven framework. (Don’t even think about Custom Settings.)
Our Custom Metadata type – let’s call it QuantityDiscount__mdt – would require the following fields:
- Account (Account__c) – Text(18) – Id of the Account the discount should apply to
- Product (Product__c) – Text(18) – Id of the Product the discount should apply to
- Minimum Quantity (MinimumQuantity__c) – Number(18,0) – Quantity from which on the discount applies
- Discount Amount (DiscountAmount__c) – Number(16,2) – Amount discounted above the given quantity.
- Discount Free Products (DiscountFreeProducts__c) – Number(18,0) – Free products granted above the given quantity.
One row in the Custom Metadata type represents one quantity discount condition per customer and product.
Some caveats as follows:
- Make sure to populate DiscountAmount__c or DiscountFreeProducts__c only, not both – otherwise the code will determine which one is used first, in our case it’s DiscountFreeProducts__c before DiscountAmount__c
- Multiple Quantity Discount levels for the same customer and product are granted serially from top to bottom: We try to use the quantity discount row with the highest quantity as often as possible, then the quantity discount row with the second highest quantity, and so on. We’re not automating the highest discount for the customer, so if your metadata discount for a higher quantity is relatively lower than your discount for a lower quantity, fix it or at least don’t allow your customers to find out.
- The logic below caters for a maximum of 3 quantity discount levels per Account and Product, but with a for loop it can be extended to an unlimited number of quantity discount levels per combination.
Now let’s work the Custom Metadata Type into trigger logic. Triggering events are data manipulations on the CartItem object, as we want the logic to fire immediately after the item has been put into the cart, not only at checkout.
First, let’s create a checkbox field on CartItem:
- Free Item (FreeItem__c) – Checkbox – Indicates whether the item is a free add-on according to the custom logic to be implemented
Then we create the Trigger Handler – you’ll need the CollectionUtils helper class that has been posted earlier.
public without sharing class CartItemTriggerHandler {
private static Boolean hasTriggerRan = false;
public static void deleteRelatedFreeItems(List<CartItem> newRecords){
List<CartItem> actualNewRecords = new List<CartItem>();
for(CartItem ci : newRecords){
if(!ci.FreeItem__c){
actualNewRecords.add(ci);
} else {
if(!hasTriggerRan){
ci.addError('You can not delete a free item on its own!');
}
}
}
Map<Id, List<CartItem>> cartItemsByCartMap = (Map<Id, List<CartItem>>) CollectionUtils.mapFromCollectionWithCollectionValues('CartId', actualNewRecords);
Map<Id, List<String>> cartItemProductsByCartMap = new Map<Id, List<String>>();
for(Id cartId : cartItemsByCartMap.keySet()){
cartItemProductsByCartMap.put(cartId, CollectionUtils.generateStringListFromSObjectList(CartItemsByCartMap.get(cartId), 'Product2Id', true, true));
}
List<CartItem> cartItemsToDelete = new List<CartItem>();
for(CartItem ci : [SELECT Id, CartId FROM CartItem WHERE FreeItem__c = TRUE AND Id IN : cartItemsByCartMap.keySet()]){
if(cartItemProductsByCartMap.containsKey(ci.CartId) && cartItemProductsByCartMap.get(ci.CartId).contains(ci.Product2Id)){
cartItemsToDelete.add(ci);
}
}
if(!cartItemsToDelete.isEmpty()){
delete cartItemsToDelete;
}
}
public static void calculateDiscount(List<CartItem> newRecords, Boolean isInsert){
hasTriggerRan = true;
// Get all WebCarts
List<String> cartIds = CollectionUtils.generateStringListFromSObjectList(newRecords, 'CartId', true, true);
// Get Accounts through Carts
Map<Id, WebCart> cartMap = new Map<Id, WebCart>([SELECT Id, AccountId FROM WebCart WHERE Id IN : cartIds]);
// Get all WebCart Accounts
List<String> accountIds = CollectionUtils.generateStringListFromSObjectList(cartMap.values(), 'AccountId', true, true);
// Get all Product IDs
List<String> productIds = CollectionUtils.generateStringListFromSObjectList(newRecords, 'Product2Id', true, true);
List<CartItem> cartItemsToDelete = [SELECT Id, Name, Type, Sku, CartDeliveryGroupId, Product2Id, CartId, FreeItem__c, UnitAdjustedPrice, Quantity FROM CartItem WHERE CartId IN : cartIds AND FreeItem__c = TRUE];
// Get all Custom Metadata records of related Accounts and Products (order by quantity desc) and group them by Account and Product in a map
Map<String, Map<String, List<QuantityDiscount__mdt>>> customMetadataMap = new Map<String, Map<String, List<QuantityDiscount__mdt>>>();
for(QuantityDiscount__mdt disc : [SELECT Id, Account__c, Product__c, DiscountFreeProducts__c, DiscountAmount__c, MinimumQuantity__c FROM QuantityDiscount__mdt WHERE Product__c IN : productIds AND Account__c IN : accountIds ORDER BY MinimumQuantity__c DESC]){
Map<String, List<QuantityDiscount__mdt>> innerMap = customMetadataMap.containsKey(disc.Account__c) ? customMetadataMap.get(disc.Account__c) : new Map<String, List<QuantityDiscount__mdt>>();
List<QuantityDiscount__mdt> innerList = innerMap.containsKey(disc.Product__c) ? innerMap.get(disc.Product__c) : new List<QuantityDiscount__mdt>();
innerList.add(disc);
innerMap.put(disc.Product__c, innerList);
customMetadataMap.put(disc.Account__c, innerMap);
}
// Actual calculation
// For Loop through Cart Items Map
List<CartItem> newCartItems = new List<CartItem>();
for(CartItem ci : newRecords){
Id accountId = cartMap.get(ci.CartId).AccountId;
if(customMetadataMap.containsKey(accountId)){
Map<String, List<QuantityDiscount__mdt>> customMetadataInnerMap = customMetadataMap.get(accountId);
// For Loop through Cart Items Map.get Account
if(customMetadataInnerMap.containsKey(ci.Product2Id)){
// Prepare any existing FreeItems__c rows for deletion
// Calculate and apply discount
List<QuantityDiscount__mdt> discountList = customMetadataInnerMap.get(ci.Product2Id);
// Variable totalDiscount
Decimal totalDiscount = 0;
// Variable totalExtraItems
Decimal totalExtraItems = 0;
// Store quantity as a temp variable
Decimal quantity = ci.Quantity;
// 1st level calculation with prio Free Products > Amount > Percent
while(quantity >= discountList[0].MinimumQuantity__c){
quantity -= discountList[0].MinimumQuantity__c;
if(discountList[0].DiscountFreeProducts__c != null){
totalExtraItems += discountList[0].DiscountFreeProducts__c;
} else if(discountList[0].DiscountAmount__c != null){
totalDiscount += discountList[0].MinimumQuantity__c * discountList[0].DiscountAmount__c;
}
}
// Cater for 2nd and 3rd level of same item
if(discountList.size() > 1){
while(quantity >= discountList[1].MinimumQuantity__c){
quantity -= discountList[1].MinimumQuantity__c;
if(discountList[1].DiscountFreeProducts__c != null){
totalExtraItems += discountList[1].DiscountFreeProducts__c;
} else if(discountList[1].DiscountAmount__c != null){
totalDiscount += discountList[1].MinimumQuantity__c * discountList[1].DiscountAmount__c;
}
}
}
if(discountList.size() > 2){
while(quantity >= discountList[2].MinimumQuantity__c){
quantity -= discountList[2].MinimumQuantity__c;
if(discountList[2].DiscountFreeProducts__c != null){
totalExtraItems += discountList[2].DiscountFreeProducts__c;
} else if(discountList[2].DiscountAmount__c != null){
totalDiscount += discountList[2].MinimumQuantity__c * discountList[2].DiscountAmount__c;
}
}
}
if(totalDiscount > 0){
Decimal originalQuantity = ci.Quantity;
//ci.Quantity = originalQuantity + totalExtraItems;
ci.TotalPrice = ((ci.Quantity * ci.UnitAdjustedPrice) - totalDiscount);
ci.TotalPriceAfterAllAdjustments = ci.TotalPrice;
ci.UnitAdjustedPrice = ci.TotalPrice / ci.Quantity;
ci.SalesPrice = ci.UnitAdjustedPrice;
}
if(totalExtraItems > 0){
CartItem ciClone = ci.clone(false, false, false, false);
ciClone.Quantity = totalExtraItems;
ciClone.UnitAdjustedPrice = 0;
ciClone.SalesPrice = 0;
ciClone.TotalPrice = 0;
ciClone.TotalPriceAfterAllAdjustments = 0;
ciClone.AdjustmentAmount = 0;
ciClone.UnitAdjustmentAmount = 0;
ciClone.TotalAdjustmentAmount = 0;
ciClone.TotalLineAmount = 0;
ciClone.FreeItem__c = true;
newCartItems.add(ciClone);
}
// End If
}
// End If
}
// End For New Item
}
if(!isInsert && !cartItemsToDelete.isEmpty()){
delete cartItemsToDelete;
}
if(!newCartItems.isEmpty()){
insert newCartItems;
}
}
}
So what is the code doing?
- The method deleteRelatedFreeItems makes sure that nobody deletes Free Items manually, and that associated Free Items are deleted if the main item is deleted.
- The Boolean hasTriggerRan makes sure that Free Items can be deleted, but only as part of the main item deletion process.
- In the main method calculateDiscount
- first all existing cart items flagged as FreeItem__c are deleted
- next we build a map from AccountId to a List of Map from ProductId to a List to Discount Items, based on all queried Custom Metadata
- then we go through all Cart Items and see if there is applicable discount for the product and account combination
- then we try to apply discount as often as possible, ordered by quantity descending
- and in the last step, we subtract any discount price calculated from the cart item, and create Free Items flagged as such by cloning the original item with all prices set to 0
And finally the Trigger itself:
trigger CartItemTrigger on CartItem (before insert, before update, after delete) {
if(Trigger.isInsert){
CartItemTriggerHandler.calculateDiscount(Trigger.new, true);
} else if(Trigger.isUpdate){
List<CartItem> cartItemQuantityChanged = new List<CartItem>();
for(CartItem ci : Trigger.new){
if(Trigger.oldMap.get(ci.Id).Quantity != ci.Quantity){
cartItemQuantityChanged.add(ci);
}
}
if(!cartItemQuantityChanged.isEmpty()){
CartItemTriggerHandler.calculateDiscount(cartItemQuantityChanged, false);
}
} else if(Trigger.isDelete){
CartItemTriggerHandler.deleteRelatedFreeItems(Trigger.old);
}
}
All set and done! Enter some custom metadata rows for your favourite customer and see the magic unraveling in your webshop.
For everyone who, at this point, needs to deal with an ERP team that insists on earmarking free and discounted items as such – there is a sequel to this on how to carry over custom fields like FreeItem__c from CartItem to OrderItem. Zero code, promised.