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.

Formula field on Quote Line

Object: SBQQ__QuoteLine__c
Label: Original Group Id
Field Name: Original_Group_Id
Type: Text
Formula (For Sales Cloud Users):

IF( NOT( ISBLANK( SBQQ__UpgradedSubscription__c ) ),
    SBQQ__UpgradedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c ,
    IF( NOT( ISBLANK( SBQQ__RenewedSubscription__c ) ),
        SBQQ__RenewedSubscription__r.SBQQ__QuoteLine__r.SBQQ__Group__c ,
        ''
    )
)

Formula (For Service Cloud Users):

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 ,
        ''
    )
)

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, Original_Group_Id__c, SBQQ__Group__c, SBQQ__Quote__c from SBQQ__QuoteLine__c where SBQQ__Quote__c in :newQuotesMap.keySet()];
        
        // 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) {
                // if this line has a original group and does not have an actual group,
                if (ql.Original_Group_Id__c != 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(ql.Original_Group_Id__c)) {
                        // add to original group id list.
                        originalGroupIdsMap.put(ql.Original_Group_Id__c, ql.Original_Group_Id__c);
                    }
                    
                    // 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 (originalGroupIdsMap.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, Original_Group_Id__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.
                        ql.SBQQ__Group__c = newGroupIdByOldGroupIdMap.get(ql.Original_Group_Id__c);
                    }
                    
                    // update grouped lines.
                    update groupedLinesMap.values();
                }
            }
        }
    }
}

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

25 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?

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.