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 formula field, a trigger, and a small 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 Price Rule will force you to use groups all the time. Right now, this is the way it has to be for technical reasons. I am actively looking for ways around this. Stay tuned.

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()]);
            
            // 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> mfields = 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>(mfields.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) {
                                SBQQ__QuoteLineGroup__c ng = new SBQQ__QuoteLineGroup__c(SBQQ__Quote__c = q.id, SBQQ__Source__c = og.id);
                                for (String fieldName : mfields.keySet()) {
                                    Schema.DescribeFieldResult field = mfields.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);
                            }
                        }
                    }
                    
                    // 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);
}

Summary Variable

Variable Name: Quote Line – Count – Grouped Lines
Target Object: Quote Line
Aggregate Function: Count
Aggregate Field: Quantity
Scope: Quote
Filter Field: SBQQ__Group__c
Operator: not equals
Filter Value: NULL

Price Rule to Inject TRUE into the SBQQ__LineItemsGrouped__c Field

Price Rule
Price Rule Name
: Quote – Inject TRUE to Group Line Items
Evaluation Scope: Calculator
Calculator Evaluation Event: Before Calculate
Active: TRUE

Price Conditions
Object: Summary Variable
Summary Variable: Quote Line – Count – Grouped Lines
Operator: greater than
Filter Type: Value
Value: 0

Price Actions
Target Object: Quote
Target Field: SBQQ__LineItemsGrouped__c
Formula: TRUE

D P

42 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. 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.

        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 🙂

  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

  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?

  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 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.

  8. 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.

  9. 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.

  10. 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.

    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.

  11. 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.

Leave a Reply to Anonymous Cancel 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.