You know how on the Product record, you can set up a Product to convert to an Asset? And you can either make it convert to an Asset One per Quote Line or One per unit? One per unit means a Quote Line with a Quantity of 5 gets 5 separate Asset records.
How great! But. What about Subscriptions? You can’t do this with Subscriptions! Gah. Not good. What if I want to convert a Subscription Product to a Subscription record, One per unit, just like Assets can be?
Well! you’ve come to the right place. 😃 Below is a solution to this requirement. It’s a couple Custom Fields, a small Apex Class, and a Process Builder. This solution works with all of the out of the box CPQ functionality. Contracts, Amendments, Renewals, MDQ, you name it. The flow is as follows.
- Create Quote with a line of a Subscription Product that has Subscription Conversion set to One per unit.
- Contract Opportunity.
- This solution will separate the Subscriptions into one record per unit just like the Asset Conversion field does.
- Amend this Contract.
- CPQ will combine all Subscriptions into one Quote Line on the Amendment Quote.
- Contract this Amendment Opportunity.
- Updated Quantities on the Amendment Quote will produce similar separated out Subscription records.
- Check the Renewal Forecast/Quoted checkbox(es).
- CPQ will combine all Subscriptions into one Quote Line on the Renewal Quote.
Custom Fields
There are two Custom Fields that are involved in this solution.
The first field is on the Product object. It’s a Picklist called Subscription Conversion (just like Asset Conversion) with the same values as Asset Conversion. The “One per unit” value will separate the Subscriptions into different records with 1 Quantity.
The second field is a Formula on the Subscription that points to the above field.
Apex Class
There is one Apex Class that provides all of the magic for this. It is set up as a batch class in case you have a giant Quantity on your Subscriptions.
public class SubscriptionHelper implements Database.Batchable<sObject> {
public static boolean ranSplitSubs = false;
public List<SBQQ__Subscription__c> subsToInsert;
// function to split subscriptions by quantity if the subscription's product's subscription conversion field is set to One per unit.
@InvocableMethod
public static void createNewSplitSubscriptionsByQuantity(List<SBQQ__Subscription__c> subs) {
if (!SubscriptionHelper.ranSplitSubs) {
SubscriptionHelper.ranSplitSubs = true;
// create subscriptions to insert list.
List<SBQQ__Subscription__c> subsToInsert = new List<SBQQ__Subscription__c>();
// create subscriptions to update list.
Map<Id, SBQQ__Subscription__c> subsToUpdateMap = new Map<Id, SBQQ__Subscription__c>();
// loop through subscriptions.
for (SBQQ__Subscription__c s : subs) {
// if this subscription's product has the subscription conversion field set to One per unit,
if (s.Subscription_Conversion__c == 'One per unit' && s.SBQQ__Quantity__c > 1 || s.SBQQ__Quantity__c < 1) {
// add original subscription to subscriptions to update list.
subsToUpdateMap.put(s.id, s);
// loop subscription . quantity times.
for (decimal i = 1; i < Math.abs(s.SBQQ__Quantity__c); i++) {
// clone subscription.
SBQQ__Subscription__c cs = s.clone();
// set pertinent values on duplicate subscriptions.
cs.id = null;
cs.SBQQ__Quantity__c = s.SBQQ__Quantity__c > 1 ? 1 : -1;
cs.SBQQ__RenewalQuantity__c = s.SBQQ__Quantity__c > 1 ? 1 : -1;
if (cs.SBQQ__RevisedSubscription__c == null) {
cs.SBQQ__RevisedSubscription__c = s.id;
}
// add cloned subscription to subscriptions to insert list.
subsToInsert.add(cs);
}
}
}
// if we have subscriptions to update,
if (subsToUpdateMap.size() > 0) {
// query subscriptions to update.
subsToUpdateMap = new Map<id, SBQQ__Subscription__c>([select Id, SBQQ__Quantity__c, SBQQ__RootId__c from SBQQ__Subscription__c where Id in :subsToUpdateMap.keySet()]);
// loop through subscriptions.
for (SBQQ__Subscription__c s : subsToUpdateMap.values()) {
// make updates.
s.SBQQ__Quantity__c = s.SBQQ__Quantity__c > 1 ? 1 : -1;
s.SBQQ__RenewalQuantity__c = s.SBQQ__Quantity__c;
s.SBQQ__RootId__c = s.id;
}
System.debug('subsToUpdateMap: '+subsToUpdateMap);
// update subscriptions to update.
update subsToUpdateMap.values();
}
// if we have subscriptions to insert,
if (subsToInsert.size() > 0) {
System.debug('subsToInsert: '+subsToInsert);
// insert subscriptions to insert.
Id batchId = Database.executeBatch(new SubscriptionHelper(subsToInsert), 100);
}
}
}
public SubscriptionHelper(List<SBQQ__Subscription__c> subs) { subsToInsert = subs; }
public Iterable<sObject> start(Database.BatchableContext bc) { return subsToInsert; }
public void execute(Database.BatchableContext bc, List<SBQQ__Subscription__c> records) { insert records; }
public void finish(Database.BatchableContext bc) {}
}
Test Apex Class
Here’s the Test Apex Class for the above Apex Class.
@isTest
public class SubscriptionHelperTester {
@isTest static void test() {
Product2 p = new Product2(Name='Test', IsActive=True, Subscription_Conversion__c='One per unit');
insert p;
Account a = new Account(Name='Test');
insert a;
Contract c = new Contract(AccountId=a.id);
insert c;
Test.startTest();
SBQQ__Subscription__c s = new SBQQ__Subscription__c(SBQQ__Product__c = p.id, SBQQ__Quantity__c=5);
insert s;
s = [select Id, SBQQ__Quantity__c, SBQQ__RootId__c, Subscription_Conversion__c, SBQQ__RevisedSubscription__c from SBQQ__Subscription__c where Id = :s.id];
SubscriptionHelper.createNewSplitSubscriptionsByQuantity(new List<SBQQ__Subscription__c>{ s });
Test.stopTest();
}
}
Flow
This Flow executes on create only and calls the Apex Class above in a Scheduled Action.
Install Links
If you are here at the end, you deserve a great reward! Below are some install links for all of the above configuration! Install and modify as needed.
Production | Sandbox
Hey Dennis! Love your posts. I was curious about your thoughts on this vs CALM for subscription management.
Are you talking about the GSA’s CALM system that’s been under development (I’m not sure what CALM is) or a CLM system that interacts with Salesforce?
If the latter, this solution, is by no means, a replacement for a CLM. However, I’ve had many experiences where CLM tools get in the way of a well functioning CPQ implementation. I always end up only using the red lining features of the CLM tool and turning off everything else.
Does this work with ‘Preserve bundle structure’ enabled?
That is a very good question! I’m pretty sure the answer is yes but I haven’t tested specifically this.
I am testing this now! It seems that an error happens. Looking more into it! Thanks for the tip!
Error: Update failed. First exception on row 0 with id a1s4x000001xo66AAA; first error: FIELD_CUSTOM_VALIDATION_EXCEPTION, Preserve Bundle Structure is checked on Contract, Renewal Quantity cannot be different from Quantity.
Hi! This issue has been resolved! There was a bug in the code that was triggering the error. 👍🏻 The installer links have been updated with corrected components.
This is great info!
I’m curious if you know how to avoid having assets on split orders duplicate themselves.
Here is the scenario….
I have an Opportunity and Primary Quote for 100 Assets.
I create two orders from the Opportunity each for 50 of the Assets.
When the orders are contracted, each order creates 100 Assets on the Account and does not recognize the split.
This results in the order processing team having to delete 100 Assets.
I know that the Asset Conversion is set-up on the Product line. We have this as one per unit, because we need to record serial numbers and warranty dates for each unit.
When I look at the Order Product the Available Quantity is the same as the Ordered Quantity.
Thanks in advance for any help. I can’t wait to read more.
Melissa
This shouldn’t happen: When the orders are contracted, each order creates 100 Assets on the Account and does not recognize the split.
I’ll have to test this out. Let me see if I can get to that today!
Hey so I tested this in my org and it doesn’t create 100 assets for each order. It just does 50 per order so something is up with your configuration. Do you have any automation around assets/order products/quote lines? Also, for each asset, look at the quote line and/or order product field. What are they all filled with?