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: Flow Scheduled Action
    1. Flow: Contract :: On After Create
      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 {

    // This function is for testing (execute anonymous) purposes!
    public static void processContract(Id contractId) {
        ContractTriggerHelper.processContracts(new List<Id> { contractId });
    }
    
    @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, StartDate, EndDate, ContractTerm 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, SBQQ__StartDate__c, SBQQ__EndDate__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, SBQQ__StartDate__c, SBQQ__EndDate__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 all fields on quote line.
                List<String> mfields = new List<String>(Schema.getGlobalDescribe().get('SBQQ__QuoteLine__c').getDescribe().fields.getMap().keySet());
                
                // 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(mfields, ',')+' 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, SBQQ__StartDate__c, SBQQ__EndDate__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;
                            
                            // Set this Quote -> Start Date = New (renewal) Contract (that fired this process) -> Start Date
                            amendmentQuotesMap.get(ql.SBQQ__Quote__c).SBQQ__StartDate__c = contractsMap.get(s.SBQQ__Contract__c).StartDate;
                            
                            // Set this Quote -> End Date = New (renewal) Contract (that fired this process) -> Start Date + New (renewal) Contract (that fired this process) -> Contract Term
                            amendmentQuotesMap.get(ql.SBQQ__Quote__c).SBQQ__EndDate__c = contractsMap.get(s.SBQQ__Contract__c).StartDate.addMonths(contractsMap.get(s.SBQQ__Contract__c).ContractTerm) - 1;
                            
                            // 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;
                                
                                // If this amendment Quote Line -> Start Date is not null,
                                if (ql.SBQQ__StartDate__c != null) {
                                    // Set this amendment Quote Line -> Start Date = this new renewal Subscription -> Start Date.
                                    ql.SBQQ__StartDate__c = s.SBQQ__StartDate__c;
                                }
                                
                                // If this amendment Quote Line -> End Date is not null,
                                if (ql.SBQQ__EndDate__c != null) {
                                    // Set this amendment Quote Line -> End Date = this new renewal Subscription -> End Date.
                                    ql.SBQQ__EndDate__c = s.SBQQ__EndDate__c;
                                }
                                
                                // 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();
    }
}

APEX Test Class – ContractTriggerHelperTester

@isTest
public class ContractTriggerHelperTester {
    static testMethod void runTest() {
        // setup
        Reassign_Amendments__c ra = new Reassign_Amendments__c(Name='test', Amendment_Opp_Stage_s__c='Prospecting', On_Off__c=True);
        insert ra;
        Product2 p = new Product2(Name='test', IsActive=True);
        insert p;
        Id pbid = Test.getStandardPricebookId();
        PricebookEntry pe = new PricebookEntry(Product2Id=p.id, Pricebook2Id=pbid, UnitPrice = 1, IsActive=True);
        insert pe;
        Account a = new Account(Name='test');
        insert a;
        // original transactions.
        Opportunity o = new Opportunity(Name='test', Pricebook2Id=pbid, AccountId=a.id, 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);
        insert q;
        SBQQ__QuoteLine__c ql = new SBQQ__QuoteLine__c(SBQQ__Quote__c=q.id, SBQQ__Product__c=p.id, SBQQ__Quantity__c=1, SBQQ__ListPrice__c=1, SBQQ__StartDate__c=Date.today(), SBQQ__EndDate__c=Date.today());
        insert ql;
        // original contract.
        Contract c = new Contract(AccountId=a.id, SBQQ__Opportunity__c=o.id);
        insert c;
        SBQQ__Subscription__c s = new SBQQ__Subscription__c(SBQQ__Contract__c=c.id, SBQQ__Product__c=p.id, SBQQ__Quantity__c=1, SBQQ__ListPrice__c=1, SBQQ__QuoteLine__c=ql.id);
        insert s;
        // renewal transactions.
        Opportunity ro = new Opportunity(Name='rtest', Pricebook2Id=pbid, AccountId=a.id, SBQQ__RenewedContract__c=c.id, StageName='Prospecting', CloseDate=Date.today());
        insert ro;
        SBQQ__Quote__c rq = new SBQQ__Quote__c(SBQQ__Account__c=a.id, SBQQ__Opportunity2__c=ro.id, SBQQ__Primary__c=True, SBQQ__MasterContract__c=c.id);
        insert rq;
        SBQQ__QuoteLine__c rql = new SBQQ__QuoteLine__c(SBQQ__Quote__c=rq.id, SBQQ__Product__c=p.id, SBQQ__Quantity__c=1, SBQQ__ListPrice__c=1, SBQQ__RenewedSubscription__c=s.id, SBQQ__StartDate__c=Date.today(), SBQQ__EndDate__c=Date.today());
        insert rql;
        // amendment transactions.
        Opportunity ao = new Opportunity(Name='rtest', Pricebook2Id=pbid, AccountId=a.id, SBQQ__AmendedContract__c=c.id, StageName='Prospecting', CloseDate=Date.today());
        insert ao;
        SBQQ__Quote__c aq = new SBQQ__Quote__c(SBQQ__Account__c=a.id, SBQQ__Opportunity2__c=ao.id, SBQQ__Primary__c=True, SBQQ__MasterContract__c=c.id);
        insert aq;
        SBQQ__QuoteLine__c aql = new SBQQ__QuoteLine__c(SBQQ__Quote__c=aq.id, SBQQ__Product__c=p.id, SBQQ__Quantity__c=1, SBQQ__ListPrice__c=1, SBQQ__UpgradedSubscription__c=s.id, SBQQ__StartDate__c=Date.today(), SBQQ__EndDate__c=Date.today());
        insert aql;
        // renewal contract.
        Contract rc = new Contract(AccountId=a.id, SBQQ__Opportunity__c=ro.id, StartDate=Date.today(), ContractTerm=12);
        insert rc;
        SBQQ__Subscription__c rs = new SBQQ__Subscription__c(SBQQ__Contract__c=rc.id, SBQQ__Product__c=p.id, SBQQ__Quantity__c=1, SBQQ__ListPrice__c=1, SBQQ__QuoteLine__c=rql.id);
        insert rs;
        update rc;
        ContractTriggerHelper.processContract(rc.id);
    }
}

Flow – Contract :: On After 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.