CPQ – Orphaned Amendments – Solution!

There came an issue with amendment Opportunities/Quotes being orphaned after a renewal was closed won and Contracted! Below is a diagram showing the issue.

The process is as follows:

  1. Everything is fine (most important step)
  2. Create new Opportunity
    1. New Quote
  3. Close win Opportunity
  4. Click Contracted = True on Opportunity (or your favorite automation)
    1. Contract is auto-created
    2. Subscriptions are auto-created
  5. Click Renewal Forecast/Quoted on Contract (or your favorite automation)
    1. Renewals are auto-created
  6. Then we begin an Amendment on the Contract
    1. Click Amend and Amend on the Contract
    2. Save the Quote
  7. Then we go back to the Renewal Opportunity and close win it! BEFORE the Amendment Opportunity is completed…
  8. Click Contracted = True on the Renewal Opportunity (or your favorite automation)
    1. New Contract is auto-created
    2. Subscriptions are auto-created
  9. This is where the problem lies. Now, we have an in-flight Amendment on the old Contract that we do not wanna have to recreate on the new Contract.

Here is the proposed solution process. Once the system creates the new Contract and Subscriptions, another process is kicked off that reassigns the in-flight amendment Opportunity/Quote/Quote Lines to the new Contract/Subscriptions.

Custom Setting and Fieldset Design

The idea for Settings and Fieldset comes from the notion that this solution should not need maintenance after it is implemented.  Therefore we give Settings and a Metadata Type to allow for adjustments to the process.

Custom Setting Definition

Custom SettingPurpose
On/OffThe On/Off setting allows for control over if the automation runs or not, in the event any of the risks are being experienced.
Amendment Opp Stage(s)The Amendment Opp Stage(s) setting will determine which amendments to reassign to the new Contract.
Make sure to insert a record into this custom setting after creating it!

Fieldset Definition

ObjectFieldset NamePurpose
Quote LineReassigned Amendment FieldsThe Reassigned Amendment Fields fieldset on the Quote Line object will control which fields to copy from renewal Subscription -> Quote Line to existing amendment Quote Line.
Make sure to add fields to this fieldset after creating it!

Process Logic Design

The logic of this new process will be implemented in a future method.  This is to mitigate any potential heap size or timeout limit issues as much as possible.

  1. Trigger Mechanism: Process Builder Scheduled Action
    1. Process Builder: Contract is Created
      1. Criteria: No Criteria
      2. SCHEDULED ACTION
        1. Schedule: 0 Days After CreatedDate
        2. Action: Call Apex
          1. Action Name: ReassignAmendments
          2. Apex Class: ContractTriggerHelper
          3. Set Apex Variables: contractIds = Field Reference: [Contract].Id
    2. If the On/Off Setting is TRUE and the Contract -> Opportunity -> Renewed Contract != NULL,
      1. Query for necessary fields from the passed in contracts.
      2. Query for Opportunities that have:
        1. Account = Account on Contract that fired this process
        2. Amended Contract = this Contract -> Opportunity -> Renewed Contract
        3. Is not closed with Stage = Amendment Opp Stage(s) setting
      3. Query for Primary Quotes that have Opportunity in the above list of Opportunity IDs.
      4. Query for Subscriptions of the new (renewal) Contract (that fired this process).
      5. Query for fields to update on reassigned amendment quote lines.
      6. Query for quote lines of the above renewal subscriptions.
      7. Query for amendment Quote Lines that have Quote = above list of Quote IDs.
      8. Loop through these contracts.
        1. Loop through amendment Opportunities.
          1. If this Opportunity -> amended Contract == this Contract -> Opportunity -> renewed Contract
            1. Set this amendment Opportunity -> Amended Contract = New (renewal) Contract
            2. Loop through amendment Quotes.
              1. Set this Quote -> Master Contract = New (renewal) Contract (that fired this process)
              2. Loop through new (renewal) Subscriptions (of the Contract that fired this process).
                1. Loop through amendment Quote Lines
                  1. If this renewal Subscription -> Quote Line -> Renewed Subscription == this amendment Quote Line -> Upgraded Subscription,
                    1. Set this amendment Quote Line -> Upgraded Subscription = this new renewal Subscription.
                    2. Loop through Reassigned Amendment Fields fieldset’s fields.
                      1. Set this amendment Quote Line -> this fieldset field = this new renewal Subscription -> Quote Line -> this fieldset field value.
      9. Save Amendment Quote Lines
      10. Save Amendment Quotes
      11. Save Amendment Opps

APEX Class – ContractTriggerHelper

public class ContractTriggerHelper {
    @InvocableMethod
    public static void processContracts(List<Id> contractIds) {
        System.debug('ContractTriggerHelper.ReassignAmendments()');
        // If the On/Off setting is TRUE and the Contract -> Opportunity -> Renewed Contract != NULL,
        List<Reassign_Amendments__c> raSettings = [SELECT id, Name, Amendment_Opp_Stage_s__c, On_Off__c FROM Reassign_Amendments__c];
        if (raSettings.size() > 0) {
            Reassign_Amendments__c raSetting = raSettings[0];
            if (raSetting.On_Off__c == true) {
                // query for necessary fields from the passed in contracts.
                Map<Id, Contract> contractsMap = new Map<Id, Contract>([select Id, ContractNumber, AccountId, SBQQ__Opportunity__r.SBQQ__RenewedContract__c from Contract where id in :contractIds]);
        System.debug('ContractTriggerHelper.ReassignAmendments(): contractsMap.size: '+contractsMap.size());
                Map<Id, Id> renewedContractIds = new Map<Id, Id>();
                for (Contract c : contractsMap.values()) {
                    if(!renewedContractIds.containsKey(c.SBQQ__Opportunity__r.SBQQ__RenewedContract__c)) {
                        renewedContractIds.put(c.SBQQ__Opportunity__r.SBQQ__RenewedContract__c, c.SBQQ__Opportunity__r.SBQQ__RenewedContract__c);
                    }
                }
        System.debug('ContractTriggerHelper.ReassignAmendments(): renewedContractIds.size: '+renewedContractIds.size());
                
                Map<Id, Id> contractAccountIds = new Map<Id, Id>();
                Map<Id, Id> renewedContracts = new Map<Id, Id>();
                for(Contract c : contractsMap.values()) {
                    if (!contractAccountIds.containsKey(c.AccountId)) {
                        contractAccountIds.put(c.AccountId, c.AccountId);
                    }
                }
        System.debug('ContractTriggerHelper.ReassignAmendments(): contractAccountIds.size: '+contractAccountIds.size());
                List<String> amendmentOppStages = raSetting.Amendment_Opp_Stage_s__c.split(',');
        System.debug('ContractTriggerHelper.ReassignAmendments(): amendmentOppStages.size: '+amendmentOppStages.size());
                
                // Query for Opportunities that have:
                //     Account = Account on Contract that fired this process
                //     Amended Contract = this Contract -> Opportunity -> Renewed Contract
                //     Is not closed with Stage = Amendment Opp Stage(s) setting
                Map<Id, Opportunity> amendmentOppsMap = new Map<Id, Opportunity>([select Id, Name, SBQQ__AmendedContract__c, SBQQ__AmendedContract__r.ContractNumber from Opportunity where AccountId in :contractAccountIds.values() and SBQQ__AmendedContract__c in :renewedContractIds.values() and StageName != 'Closed Won' and StageName != 'Closed Lost' and StageName in :amendmentOppStages]);
        System.debug('ContractTriggerHelper.ReassignAmendments(): amendmentOppsMap.size: '+amendmentOppsMap.size());
                
                // Query for Primary Quotes that have Opportunity in the above list of Opportunity IDs.
                Map<Id, SBQQ__Quote__c> amendmentQuotesMap = new Map<Id, SBQQ__Quote__c>([select Id, SBQQ__Opportunity2__c, SBQQ__MasterContract__c from SBQQ__Quote__c where SBQQ__Opportunity2__c in :amendmentOppsMap.keySet() and SBQQ__Primary__c = true]);
        System.debug('ContractTriggerHelper.ReassignAmendments(): amendmentQuotesMap.size: '+amendmentQuotesMap.size());
               
                // Query for Subscriptions of the new (renewal) Contract (that fired this process).
                List<SBQQ__Subscription__c> renewalSubscriptions = [select Id, SBQQ__Contract__c, SBQQ__QuoteLine__c, SBQQ__QuoteLine__r.SBQQ__RenewedSubscription__c from SBQQ__Subscription__c where SBQQ__Contract__c in :contractsMap.keySet()];
        System.debug('ContractTriggerHelper.ReassignAmendments(): renewalSubscriptions.size: '+renewalSubscriptions.size());
                
                Map<Id, Id> renewalSubscriptionQuoteLineIds = new Map<Id, Id>();
                for(SBQQ__Subscription__c s : renewalSubscriptions) {
                    if (!renewalSubscriptionQuoteLineIds.containsKey(s.SBQQ__QuoteLine__c)) {
                        renewalSubscriptionQuoteLineIds.put(s.SBQQ__QuoteLine__c, s.SBQQ__QuoteLine__c);
                    }
                }
                
                // Query for fields to update on reassigned amendment quote lines.
				List<Schema.FieldSetMember> fieldSetMemberList =  ContractTriggerHelper.readFieldSet('Reassigned_Amendment_Fields','SBQQ__QuoteLine__c');
                List<String> fieldList = new List<String>();
                for(Schema.FieldSetMember fieldSetMemberObj : fieldSetMemberList) {
                    fieldList.add(fieldSetMemberObj.getFieldPath());
                }
        System.debug('ContractTriggerHelper.ReassignAmendments(): fieldList.size()'+fieldList.size());
                
                // Query for quote lines of the above renewal subscriptions.
                Map<Id, SBQQ__QuoteLine__c> renewalSubscriptionQuoteLinesMap = new Map<Id, SBQQ__QuoteLine__c>((List<SBQQ__QuoteLine__c>)Database.query('select '+String.join(fieldList, ',')+' from SBQQ__QuoteLine__c where Id in (\'' + String.join(renewalSubscriptionQuoteLineIds.values(), '\',\'') + '\')'));
        System.debug('ContractTriggerHelper.ReassignAmendments(): renewalSubscriptionQuoteLinesMap.size(): '+renewalSubscriptionQuoteLinesMap.size());
                
                // Query for amendment Quote Lines that have Quote = above list of Quote IDs.
                List<SBQQ__QuoteLine__c> amendmentQuoteLines = [select Id, SBQQ__Quote__c, SBQQ__UpgradedSubscription__c from SBQQ__QuoteLine__c where SBQQ__Quote__c in :amendmentQuotesMap.keySet()];
        System.debug('ContractTriggerHelper.ReassignAmendments(): amendmentQuoteLines.size(): '+amendmentQuoteLines.size());
                
                // Loop through new (renewal) Subscriptions (of the Contract that fired this process).
                for (SBQQ__Subscription__c s : renewalSubscriptions) {
                    // Loop through amendment Quote Lines
                    for (SBQQ__QuoteLine__c ql : amendmentQuoteLines) {
                        // if this opportunity -> amended contract == this contract -> opportunity -> renewed contract
                        if (amendmentOppsMap.get(amendmentQuotesMap.get(ql.SBQQ__Quote__c).SBQQ__Opportunity2__c).SBQQ__AmendedContract__c == contractsMap.get(s.SBQQ__Contract__c).SBQQ__Opportunity__r.SBQQ__RenewedContract__c) {
                            // Set this amendment Opportunity -> Name = Set this amendment Opportunity -> Name, but replace the old contract number with the new contract number.
                            amendmentOppsMap.get(amendmentQuotesMap.get(ql.SBQQ__Quote__c).SBQQ__Opportunity2__c).Name = amendmentOppsMap.get(amendmentQuotesMap.get(ql.SBQQ__Quote__c).SBQQ__Opportunity2__c).Name.replace(amendmentOppsMap.get(amendmentQuotesMap.get(ql.SBQQ__Quote__c).SBQQ__Opportunity2__c).SBQQ__AmendedContract__r.ContractNumber, contractsMap.get(s.SBQQ__Contract__c).ContractNumber);
                            
                            // Set this amendment Opportunity -> Amended Contract = New (renewal) Contract
                            amendmentOppsMap.get(amendmentQuotesMap.get(ql.SBQQ__Quote__c).SBQQ__Opportunity2__c).SBQQ__AmendedContract__c = contractsMap.get(s.SBQQ__Contract__c).Id;
                            
                            // Set this Quote -> Master Contract = New (renewal) Contract (that fired this process)
                            amendmentQuotesMap.get(ql.SBQQ__Quote__c).SBQQ__MasterContract__c = contractsMap.get(s.SBQQ__Contract__c).Id;
                            
                            // If this renewal Subscription -> Quote Line -> Renewed Subscription == this amendment Quote Line -> Upgraded Subscription,
                            if (s.SBQQ__QuoteLine__r.SBQQ__RenewedSubscription__c == ql.SBQQ__UpgradedSubscription__c) {
                                // Set this amendment Quote Line -> Upgraded Subscription = this new renewal Subscription.
                                ql.SBQQ__UpgradedSubscription__c = s.Id;
                                
                                // Loop through Reassigned Amendment Fields fieldset’s fields.
                                for(Schema.FieldSetMember fieldSetMemberObj : fieldSetMemberList) {
                                    // Set this amendment Quote Line -> this fieldset field = this new renewal Subscription -> Quote Line -> this fieldset field value.
                                    ql.put(fieldSetMemberObj.getFieldPath(), renewalSubscriptionQuoteLinesMap.get(s.SBQQ__QuoteLine__c).get(fieldSetMemberObj.getFieldPath()));
                                }
                            }
                        }
                    }
                }
                
                update amendmentOppsMap.values();
                update amendmentQuotesMap.values();
                update amendmentQuoteLines;
            }
        }
    }
    
    public static List<Schema.FieldSetMember> readFieldSet(String fieldSetName, String ObjectName) {
        Map<String, Schema.SObjectType> GlobalDescribeMap = Schema.getGlobalDescribe(); 
        Schema.SObjectType SObjectTypeObj = GlobalDescribeMap.get(ObjectName);
        Schema.DescribeSObjectResult DescribeSObjectResultObj = SObjectTypeObj.getDescribe();
        Schema.FieldSet fieldSetObj = DescribeSObjectResultObj.FieldSets.getMap().get(fieldSetName);
        return fieldSetObj.getFields();
    }
}

Process Builder – Contract – Create

And now you’re at the end! Of this post. Which means you get a prize. Here is an install link for the above junk in a production org and/or dev org. Here‘s one for sandboxes. Make sure to insert a Custom Setting record and add any fields you want copied to the Quote Line fieldset.

D P

Comments

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.