Salesforce CPQ – Pull Quote Line Groups into Renewals and Amendments

Hello! If you’re here, you’re looking for how to pull groups into your amendments and/or renewals! You might have come from the Salesforce Idea page or perhaps google search….

There is a checkbox field, a trigger and a Price Rule involved. Fear not! All of it is right here.

Please NOTE: This code is provided as is. It might not work in your org. Creating these things requires a system administrator profile and access to all objects/fields involved. The trigger code may fail based on your org’s validation rules or other automation. It also might cause an APEX timeout because of this processing time + existing automation processing time. The price rule may fail because of other automation or price/product rules/validations you have in place. So, put these things in place, test it out, modify where necessary to fit your org.

Please NOTE: The Custom Checkbox field and Price Rule are to get around a “Legacy Renewal Service” issue. If you have the Legacy Renewal Service turned on, CPQ will not let the trigger set the Group Line Items field on the Quote object. So we set a custom version of this field (Has_Groups__c) to TRUE and then the Price Rule conditions on this custom field and checks the Group Line Items CPQ field on the Quote. If this Price Rule does not run, you will get a forever spinning “waiting” dots image because of a javascript error. Essentially, there are groups on the quote but the line editor doesn’t think there is because the checkbox is unchecked so it kabooms and waits forever.

Checkbox Field
Quote -> Has Groups (Has_Groups__c)

Quote Trigger Helper Class
Name: QuoteTriggerHelper

public class QuoteTriggerHelper {
    public static void runTriggers(Map<Id, SBQQ__Quote__c> newQuotesMap) {
        if (Trigger.isAfter) {
            if (Trigger.isUpdate) {
                QuoteTriggerHelper.insertMissingAmendmentRenewalLineGroups(newQuotesMap);
            }
        }
    }
    
    public static void insertMissingAmendmentRenewalLineGroups(Map<Id, SBQQ__Quote__c> newQuotesMap) {
        // query new lines.
        List<SBQQ__QuoteLine__c> newLines = [select id, SBQQ__RenewedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c, SBQQ__UpgradedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c, SBQQ__Group__c, SBQQ__Quote__c from SBQQ__QuoteLine__c where SBQQ__Quote__c in :newQuotesMap.keySet() and SBQQ__Quote__r.SBQQ__Type__c != 'Quote' and (SBQQ__RenewedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c != null or SBQQ__UpgradedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c != null)];
        
        System.debug('newLines size: '+newLines.size());
        
        // if we have new lines,
        if (newLines.size() > 0) {
            Map<Id, Id> newQuoteIdsMap = new Map<Id, Id>();
            Map<Id, Id> originalGroupIdsMap = new Map<Id, Id>();
            Map<Id, SBQQ__QuoteLine__c> groupedLinesMap = new Map<Id, SBQQ__QuoteLine__c>();
            // loop through lines.
            for (SBQQ__QuoteLine__c ql : newLines) {
                Id originalGroupId = ql.SBQQ__RenewedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c != null ? ql.SBQQ__RenewedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c : ql.SBQQ__UpgradedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c;
                
                // if this line has a original group and does not have an actual group,
                if (originalGroupId != null && ql.SBQQ__Group__c == null) {
                    if (!newQuoteIdsMap.containsKey(ql.SBQQ__Quote__c)) {
                        // add to new quote ids map.
                        newQuoteIdsMap.put(ql.SBQQ__Quote__c, ql.SBQQ__Quote__c);
                    }
                    
                    // if we don't already have this group id in the list,
                    if (!originalGroupIdsMap.containsKey(originalGroupId)) {
                        // add to original group id list.
                        originalGroupIdsMap.put(originalGroupId, originalGroupId);
                    }
                    
                    // add to grouped lines list.
                    groupedLinesMap.put(ql.id, ql);
                }
            }
            
            // query for new quotes list.
            newQuotesMap = new Map<id, SBQQ__Quote__c>([select id, SBQQ__LineItemsGrouped__c from SBQQ__Quote__c where id in :newQuoteIdsMap.values() and SBQQ__LineItemsGrouped__c = false and (SBQQ__Type__c = 'Renewal' or SBQQ__Type__c = 'Amendment')]);
            
            // if we have quotes with original group ids but no group set,
            if (newQuotesMap.size() > 0) {
                // survey the republic for the existence of a sufficient army.
                List<SBQQ__QuoteLineGroup__c> cloneArmy = [select id, SBQQ__Source__c from SBQQ__QuoteLineGroup__c where SBQQ__Quote__c in :newQuoteIdsMap.values() and SBQQ__Source__c != null];
                
                // if the republic requires a clone army,
                if (cloneArmy.size() == 0) {
                    // CLONE QUOTE LINE GROUPS
                    
                    // get all fields of quote line group.
                    Map<String,Schema.SObjectField> qlgfields = Schema.getGlobalDescribe().get('SBQQ__QuoteLineGroup__c').getDescribe().fields.getMap();
                    
                    // query original groups.
                    List<Id> originalGroupIds = originalGroupIdsMap.values();
                    List<SBQQ__QuoteLineGroup__c> originalGroups = Database.query('select '+string.join(new List<String>(qlgfields.keySet()), ',')+' from SBQQ__QuoteLineGroup__c where id in :originalGroupIds');
                    
                    // contact Kamino government to commission the creation of a quote line group clone army.
                    cloneArmy = new List<SBQQ__QuoteLineGroup__c>();
                    
                    // loop through quotes.
                    for (SBQQ__Quote__c q : newQuotesMap.values()) {
                        if (!q.SBQQ__LineItemsGrouped__c) {
                            // set this quote to have grouped lines.
                            q.SBQQ__LineItemsGrouped__c = true;
                        }
                        
                        // if we have original group ids,
                        if (originalGroups.size() > 0) {
                            // loop through clone army.
                            for (SBQQ__QuoteLineGroup__c og : originalGroups) {
                                // create clone marking it with the source.
                                SBQQ__QuoteLineGroup__c ng = new SBQQ__QuoteLineGroup__c(SBQQ__Quote__c = q.id, SBQQ__Source__c = og.id);
                                
                                // loop through quote line group fields.  put all values into writeable fields.
                                for (String fieldName : qlgfields.keySet()) {
                                    Schema.DescribeFieldResult field = qlgfields.get(fieldName).getDescribe();
                                    if (field.isUpdateable() && fieldName != 'Id' && fieldName != 'SBQQ__Quote__c' && fieldName != 'SBQQ__Source__c' && og.get(fieldName) != null) {
                                        try {
                                            ng.put(fieldName, og.get(fieldName));
                                        } catch (Exception e) { }
                                    }
                                }
                                
                                cloneArmy.add(ng);
                            }
                        }
                    }
                    
                    update newQuotesMap.values();
                    
                    // if we have a clone army,
                    if (cloneArmy.size() > 0) {
                        // insert clone army.
                        insert cloneArmy;
                    }
                }
                
                // UPDATE QUOTE LINES
                
                // make old group id -> new group id map.
                Map<Id, Id> newGroupIdByOldGroupIdMap = new Map<Id, Id>();
                
                // loop through clone army.
                for (SBQQ__QuoteLineGroup__c g : cloneArmy) {
                    // add source -> id to the old group id -> new group id map.
                    newGroupIdByOldGroupIdMap.put(g.SBQQ__Source__c, g.id);
                }
                
                // if we have grouped lines,
                if (groupedLinesMap.size() > 0) {
                    // requery grouped lines.
                    groupedLinesMap = new Map<Id, SBQQ__QuoteLine__c>([select id, SBQQ__Group__c, SBQQ__RenewedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c, SBQQ__UpgradedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c from SBQQ__QuoteLine__c where id in :groupedLinesMap.keySet()]);
                    
                    // loop through grouped lines.
                    for (SBQQ__QuoteLine__c ql : groupedLinesMap.values()) {
                        // update to new group.
                        if (ql.SBQQ__RenewedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c != null) {
    	                    ql.SBQQ__Group__c = newGroupIdByOldGroupIdMap.get(ql.SBQQ__RenewedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c);
                        } else if (ql.SBQQ__UpgradedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c != null) {
	                        ql.SBQQ__Group__c = newGroupIdByOldGroupIdMap.get(ql.SBQQ__UpgradedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c);
                        }
                    }
                    
                    // update grouped lines.
                    update groupedLinesMap.values();
                }
            }
        }
    }
}

Quote Trigger Helper Tester Class
Name: QuoteTriggerHelperTester

@isTest (seealldata=false)
public class QuoteTriggerHelperTester {
    static testMethod void convertProductToMDQYearlySubscription() {
        // set up foundation.
        Product2 p = new Product2(Name='Test', IsActive=true, SBQQ__PricingMethod__c='List', SBQQ__SubscriptionPricing__c='Fixed Price', SBQQ__SubscriptionType__c='Renewable', SBQQ__SubscriptionTerm__c=12);
        insert p;
        
        Id pbid = Test.getStandardPricebookId();
        PricebookEntry pbe = new PricebookEntry(Product2Id=p.id, Pricebook2Id=pbid, UnitPrice=1, IsActive=true);
        insert pbe;
        
        Account a = new Account(Name='Test');
        insert a;
        
        // set up first opp and quote scenario.
        Opportunity o = new Opportunity(AccountId=a.id, Name='Test', StageName='Prospecting', CloseDate=Date.today());
        insert o;
        
        SBQQ__Quote__c q = new SBQQ__Quote__c(SBQQ__Account__c=a.id, SBQQ__Opportunity2__c=o.id, SBQQ__Primary__c=true, SBQQ__StartDate__c=Date.today(), SBQQ__SubscriptionTerm__c=12);
        insert q;
        
        SBQQ__QuoteLineGroup__c qlg = new SBQQ__QuoteLineGroup__c(SBQQ__Account__c=a.id, SBQQ__Quote__c=q.id, Name='Test');
        insert qlg;
        
        SBQQ__QuoteLine__c ql = new SBQQ__QuoteLine__c(SBQQ__Quote__c=q.id, SBQQ__Group__c=qlg.id, SBQQ__Product__c=p.id, SBQQ__Quantity__c=1, SBQQ__SubscriptionPricing__c='Fixed Price', SBQQ__ProductSubscriptionType__c='Renewable', SBQQ__SubscriptionType__c='Renewable', SBQQ__DefaultSubscriptionTerm__c=12, SBQQ__SubscriptionTerm__c=12, SBQQ__StartDate__c=Date.today());
        insert ql;
        
        // set up contract and subscriptions.
        Contract c = new Contract(AccountId=a.id, SBQQ__Opportunity__c=o.id, SBQQ__Quote__c=q.id, Status='Draft', StartDate=Date.today(), ContractTerm=12, SBQQ__PreserveBundleStructureUponRenewals__c=true);
        insert c;
        
        SBQQ__Subscription__c s = new SBQQ__Subscription__c(SBQQ__Account__c=a.id, SBQQ__Contract__c=c.id, SBQQ__Product__c=p.id, SBQQ__QuoteLine__c=ql.id, SBQQ__Quantity__c=1, SBQQ__ListPrice__c=1, SBQQ__NetPrice__c=1, SBQQ__CustomerPrice__c=1, SBQQ__RenewalQuantity__c=1);
        insert s;
        
        Test.startTest();
        // create renewals.
        c.SBQQ__RenewalForecast__c = true;
        c.SBQQ__RenewalQuoted__c = true;
        update c;
        Test.stopTest();
    }
}

Quote Trigger
Name: QuoteTriggers
Object: SBQQ__Quote__c

trigger QuoteTriggers on SBQQ__Quote__c (after update) {
	QuoteTriggerHelper.runTriggers(Trigger.newMap);
}

Price Rule
This Price Rule is only for the legacy Renewal service (see the second note at the top). The new Renewal service does not have an issue with setting the Grouped Line Items checkbox on Quote but the legacy service does. We have no idea which service you’re using so this solution accommodates both!

Price Rule Name: Quote – Inject TRUE to Group Line Items

And now here’s a couple install links for the things above.
Sandbox | Production

After you install, be sure to run the below script in the execute anonymous popup in the developer console. Click here to open the developer console in production and here to open it in sandbox.

MoreCPQPostInstall.PullQuoteLineGroupsIntoRenewalsandAmendments_PostInstall();

D P

52 thoughts on “Salesforce CPQ – Pull Quote Line Groups into Renewals and Amendments

  1. This is great, thanks. For users out there who use Service Cloud for CPQ, you can use the exact same code and rules, but the formula field needs to change a little bit. Here’s that formula:

    IF( NOT( ISBLANK( SBQQSC__UpgradedContractLine__c ) ),
    SBQQSC__UpgradedContractLine__r.SBQQSC__QuoteLine__r.SBQQ__Group__c ,
    IF( NOT( ISBLANK( SBQQSC__RenewedContractLine__c ) ),
    SBQQSC__RenewedContractLine__r.SBQQSC__QuoteLine__r.SBQQ__Group__c ,

    )
    )

  2. Question on the price rule – in your experience do you not need a “false” pairing to the “true” you created? In other words, if I want to un-group a renewal quote, is it enough to delete the groups and save, or do I need a price rule that sets grouping to false based on a sumvar count of 0?

    1. Ah yes, this is a good thought. How to “undo” this thing… Will the rule kill my ungroupings or set something wonky… The short answer is no. The long answer is: CPQ usually manages this checkbox for you as you group/ungroup in the line editor. But, for some reason, it does not do this when groups get inserted via trigger like the above. It omits the evaluation of grouped lines and setting of this field, hence the price rule is needed. In the trigger, it always thinks there are no grouped lines.

  3. Hi Dennis,

    It’s great to be able to pull the groups into the amendment and renewal quotes! I am testing the code, but when I tried to open the quotes updated by the code, the quote line editor failed to load (just hang there), did you experience this before?

    Thanks.

    Jacky

    1. I had to hit the calculate button manually on the quote page, after the calculation completes, then get into the QLE. Not sure if it is something from our sandbox.

      1. Have you implemented the Price Rule? This will happen if the Price Rule isn’t firing or isn’t there.

      2. Ah! This is the thing that is missing! In the package settings, set “Calculate Immediately” to true.

        1. I was experiencing the same issue and setting Filter Value explicitely to NULL for the summary variable instead of leaving it blank did the trick for me!

        2. I’m also encountering the same issue. I have to hit calculate first before the group line items to be set to TRUE.

          1. There is a solution! In CPQ Package Settings, set Calculate Immediately to true!

        3. Yes, I have implemented Price rule and activated.
          I noticed that ‘Group Line Items’ in Quote is not updating to TRUE. This is the reason it just keeps loading on Quote Line Editor. When I manually make it to TRUE on the amendment Quote, the line editor loads and retains the Groups.

          1. Thank you Yamin S. Your solution works!. I have explicitly given NULL in the Filter Value of a summary variable. Thank you D P 🙂

          2. This is not working as expected when there is a package (product with product options)

  4. Dennis, I have activated the Price Rule but it will not fire automatiaclly after a renewal or amendment causing the QLE to be blank. I need to edit and save the QL to force a recalulation then it works great. From the other notes it appears this has happened to others. Was a solution ever found for this? Thanks Greg

    1. This is a great idea. Will update the article Monday morning to incorporate this. Thanks for the feedback!

  5. Adding on to Chris’ thoughts, is there any way to use twin fields and/or QCP to simplify or eliminate the use of code, and to minimize performance impacte?

          1. I have not! Even if it were a QCP, it would involve an APEX web service that does the group creating. The next evolution of this solution will be a callable method that is called by a scheduled process builder action so the code is executed outside of the standard CPQ processing.

  6. I am still not able to get this to work. One of the 2 issues happens:

    1) It just spins when I do the amendment (in that case, everything gets created as expected, just have to click out and back in like others have mentioned). I have Calculate Immediately on.

    2) When I tried to do the ‘NULL’ instead of leaving filter value blank in the summary variable (as some mentioned resolved their issue), it doesn’t spin anymore but it just groups all of my lines into 1 big group rather than splitting them out as expected. I’m not overly shocked by this considering every line is going to have SBQQ__Group__c != ‘NULL’ since its either an ID or blank and would recommend anyone else who tried this method to re-look and make sure it is functioning correctly.

    Any help would be greatly appreciated.

    1. 1) The spinning happens because the Group Line Items field is not checked. The explicit NULL resolves this.
      2) I’ve updated the trigger code. Try it and let me know if it’s still is grouping in one big group.

      The reason 2 was happening is because it is not creating the groups (bug in code) and then the price rule flags the quote as grouped so the line editor makes a group and assigns it to every line.

      1. Hello, this is great! Thank you for sharing this information.

        I am trying this out in the Sandbox and sometimes I am getting an error of ‘No metadata was retrieved for field null.Name’ from the Quote Line Editor screen. Is that related to this? I updated the code per your note, but still seem to be seeing the error.

        Thanks!

  7. Hi DP, do you have a test class for the helper class? I am not a developer and need some help in doing this one.

  8. Hi Denise, thank you a bunch for writing this article. I have followed the instructions that you provided, however when I go to test this, I created a quote with 2 groups, contracted it, and amended it, however, my amendment quote only contains one group always called “Group 1”; I am not getting any error messages or hangups. Any ideas what the problem could be? Thank you in advance.

    1. Hi Brian,

      For some reason, your groups are not being created by the trigger. Then the price rule is setting it as grouped. I am going to re-implement this in a fresh org to see if I can reproduce this issue as it is happening to a few of you.

      1. Hi Dennis, thanks for the help on this, however, I am still seeing issues.

        – When I try to amend an existing contract with groups, CPQ tries to load the line editor page but instead, the page just tries to load forever.
        – I am still seeing odd formatting loading on the line editor page for segmented products
        – Creating a new quote always creates a group; I think you said in another post that for now it is required, however our business case doesn’t always require groups on quotes, and grouping does affect the quote line section on quote templates, so if something could be done about that, that would be great.

        Thanks!
        -Brian

        1. Yes I am looking for solutions to the “groups are required” part of this. For now there is no solution as CPQ unchecks that box if I check it directly in the code.

          I’ll be adding segmented products to my testing agenda for the next release of this solution.

  9. Hi Dennis,

    Even after adding the NULL in the filter value of the summary variable and updating to your most recent Apex class and trigger code, my groups are not persisting and are being combined into one group for both Renewal and Amendments. I have checked to ensure Calculate Immediately is checked in the CPQ settings, the price rule is active and the trigger is active. I there anything else I could check?

    Thanks so much.

  10. Hi Dennis,

    After going through the latest here in terms of the Apex class, trigger and adding NULL in the summary variable. We are experiencing the issue where the groups are combined in to one group. Calculate Immediately is check on as well. Is there anything else we can check or that might cause this? Note, posting again as I am not sure my original post went through.

    Thanks so much.

  11. Hi Dennis,

    Another thing I am now noticing is when I go to add products in the QLE, when I add a product it is automatically put into a group. Hitting ungroup does not take the product out of the group. It’s as if I am forced to use groups.

    Thanks for any thoughts here. Matt

    1. Hey Matt,

      A couple things about all the comments!

      – For some reason, your groups are not being created by the trigger. Then the price rule is setting it as grouped. I am going to re-implement this in a fresh org to see if I can reproduce this issue as it is happening to a few of you.

      – Yes the price rule makes it so that all quotes have groups. You can put in a price condition so only renewal/amendment quotes with original lines that have groups fire this rule.

      1. Thanks, Dennis, I really appreciate the help! Some additional details- it seems like whenever any quote is created, there is always a “Group 1” present that cannot be removed. Also, the formatting for our segmented products seems to get wacky.

    2. FYI: This solution requires groups. The Price Rule forces it. I am actively looking for a way around this but for now, you’re forced into groups.

  12. Hi DP,

    We used this and working as expected but after deployment instead of just recreating the QL groups, it is also creating extra groups. For example, 4 QL groups on the Original Quote, on Amendment/Renew it is now 8.

  13. Hi DP,

    Thanks for the fix. We used this and working as expected but after re-amendment, grouping not working as expected, it is also creating extra groups. For example, 2 QL groups on the Original Quote, on Amendment/Renew it is now 4 and if it has 4 QL groups its creating 8 new grouping.

    1. Yes! This is a known bug. I’ll be reviewing the solution to accommodate this. Thanks for the feedback!

  14. Hi Dennis,
    This is such an important functionality, unfortunate that it is not out of the box. Thank you for posting this solution here. Just wanted to ask if you are aware of any other workarounds to the endless spinning upon entering the Quote Line Editor and once in the QLE, the Quote Groups not persisting. I see that this is an issue that other users have faced, I have tried turning on Calculate Immediately and explicitly using NULL in the Summary Variable but to no avail.

    Thank you for any thoughts,
    Erik

    1. Hi Erik!

      The endless spinning is because of the “Group Line Items” checkbox not being checked. It turns out, currently, to have this solution, you must always check this box on all Quotes for it to work properly at all times.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.