SaaS mit Salesforce: Automatische Produkt Revenue Schedules mit Process Builder und invocableMethod

Als Gregor Samsa eines Morgens aus heiterem Himmel beschloß, den Handel mit Generatoren aufzugeben, gründete er eine Software as a Service Gesellschaft.//

Die App hat sich zum Verkaufschlager unter den Landvermessern entwickelt und läßt keine Kartographenwünsche offen. Salesforce bisher auch nicht. Schon seit der Zeit der Großvater-GenWatt-Generatoren hat Samsa Salesforce im Einsatz. Seine neue Firma funktioniert aber nicht mehr so gut in einer Developer-Edition, weswegen er mit Enterprise Lizenzen auch gleich das Engagement Frau Josefine K.s, einer erfahrene Salesforce Designerin, erworben hat.

Die Aufgabe

Verschiedene Add-Ons werden im Abo-Modell angeboten, einige auch als Einmal-Zahlung ohne wiederkehrende Gebühren. Die Laufzeiten im Abo-Fall werden individuell verhandelt und ändern sich häufig. Das erneute, manuelle Anlegen von Schedules - die Standard Funktion für Default Product Schedules ist bereits aktiviert - empfindet der Vertrieb als eine Zumutung, was Josefine tief mitempfindet. Samsa besteht auf tagesaktuelle Zahlen, Opportunitäten sollen stets auf dem neuesten Stand gehalten werden. Alle stöhnen.

Josefine nimmt sich ein Herz und der Sache an. Nach guten und freundlichen Gesprächen kommt sie auf folgende Eckpunkte:

  • Für alle Opportunity Produkte sollen automatische Product Revenue Schedules erstellt werden
  • Die Laufzeit soll auf der Opportunity abgebildet werden (Josefines Vorschlag)
  • Anhand von Quantity und SalesPrice wird mittels der Laufzeit ein Product Revenue Schedule errechnet und angelegt
  • Die Laufzeit wird einheitlich in Monaten angegeben, die Preise sind Monatspreise mit Ausnahme von Sofort-Kauf Produkten.
  • Sofern kein eigenes VertragsStartDatum angegeben, wird das CloseDate als dieses verwendet. Es ist das StartDatum der Revenue Berechnung.
  • Wird das CloseDate editiert und liegt später als der aktuelle VertragsStart, wird der VertragsStart angepasst.
  • Veränderungen beim StartDatum, der Laufzeit, der Anzahl eines Produktes oder dessen Preises haben eine Neu-Berechnung zur Folge
  • Josefine konnte heraushören, daß bald weitere Kriterien eine Neu-Berechnung auslösen sollen
  • Der Revenue Schedule für Produkte mit Einmal-Zahlung sollen +/- N Tage auf den VertragsStart gelegt werden.

Die Idee

Inbesondere um letzteren drei Punkten Rechnung zu tragen, entscheidet sich Josefine K. für einen gemischten Ansatz: Das Heavy-Lifting übernimmt die Opportunity Service Apex-Klasse. Diese wird per @invocableMethod in den Process Builder geklinkt. Der Opportunities Process entscheidet dann, ob eine Neu-Berechnung stattfinden soll.

Im Vorfeld hat sie sich schlau gemacht, wie Process Builder mit einer großen Anzahl von Objekten umgeht. Dabei hat Josefine festgestellt, daß ihre Sorgen unbegründet waren: Process Builder ist bulkified, er läuft in Batches von 200 sObjekten.

Die Umsetzung

Custom Fields

Als erstes erstellt Josefine K. in ihrer Developer Sandbox zwei Custom Fields auf Product2 für ihren ersten Prototypen.

  • isOneTime__c, Boolean, das Produkt wird einmalig berechnet.
  • isRecurring__c, Boolean, das Produkt wird als Abo behandelt

Zusätzlich richtet sie einen Workflow samt Field Updates ein, der dafür sorgt, daß immer eines, aber nie beide Felder bei einem Produkt aktiv ist. Schaltet man das eine Feld ab, wird das andere aktiviert.

Als nächstes erstellt Josefine zwei weitere Custom Fields auf Opportunity.

  • ContractStartDate__c, Date
  • ContractTerm__c, Number 18,0, Default: 12

Dann beginnt sie Tests und Code zu schreiben.

Apex

//SNIP FROM OpportunitiesServiceTest
@istest public static void testScheduleCreate(){
  Test.startTest();
  Id idOliId = [select id from OpportunityLineItem limit 1].Id;
  List<OpportunityLineItemSchedule> listSchedules =
 OpportunitiesService.createOpportunityLineItemRevenueSchedule(idOliId,12,System.Today(),100.00);
  Test.stopTest();
  System.assertEquals(12, listSchedules.size(),'Schedule Size must match Term');
  System.assertEquals(System.Today().addmonths(11), listSchedules[11].ScheduleDate,'Last ScheduleDate must match');
}
 
//SNIP FROM OpportunitiesService
@testvisible static List<OpportunityLineItemSchedule> createOpportunityLineItemRevenueSchedule(Id OpportunityLineItemId, Integer ContractTerm, Date ContractStartDate, Decimal Price){
List<OpportunityLineItemSchedule> listNewItemSchedules = 
 new List<OpportunityLineItemSchedule>();
For (Integer i = 0; i < ContractTerm; i++){
      OpportunityLineItemSchedule installment = 
       new OpportunityLineItemSchedule(OpportunityLineItemId = 
      OpportunityLineItemId,
      Type = 'Revenue',
      Revenue = Price,
      ScheduleDate = ContractStartDate.addmonths(i));
      listNewItemSchedules.add(installment);
    }
    return listNewItemSchedules;
  }

Die Testklasse stellt eine Opportunity mit einer Laufzeit von 12 Monaten und jeweils einem Produkt je Zahlungsmodell zur Verfügung. Josefine hatte dafür das @testSetup der OpportunitiesServiceTest Klasse angepaßt.

//SNIP FROM OpportunitiesServiceTest
@istest public static void testScheduleCreateFromItems(){
  List<OpportunityLineItem> listOlis = new List<OpportunityLineItem>(
    [select id, 
    Quantity,
    Opportunity.CloseDate,
    opportunity.ContractTerm__c,
    opportunity.contractStartDate__c,
    OpportunityId,
    UnitPrice,
    Product2.isRecurring__c,
    Product2.isOneTime__c,
    FROM OpportunityLineItem]);
  // create schedule for one item. re-calculation deletes current schedule
  OpporunitiesService.createOpportunityLineItemRevenueSchedule(listOlis[0].Id,12,System.Today(),100.00);
  test.StartTest();
  OpporunitiesService.calculateOpportunityLineItemsRevenueSchedule(listOlis);
  test.StopTest();
  //setup contains 1 Recurring, 1 One Time item with 12m term, there should be 13 schedules in total; the old one got deleted
  System.Assert([Select Id from OpportunityLineItemSchedule].size()==13,'13 Schedules expected');
}

//SNIP From OpportunitiesService
@testvisible static void calculateOpportunityLineItemsRevenueSchedule(List<OpportunityLineItem> listItems){
  List<OpportunityLineItemSchedule> listNewItemSchedules = new List<OpportunityLineItemSchedule>();
  List<Id> listOpportunityLineItemIds = new List<Id>(); 
  For (OpportunityLineItem item : listItems){
    if(item.Product2.isRecurring__c){
      //ContractTerm installments, starting Contract StartDate
 	listNewItemSchedules.addall(createOpportunityLineItemRevenueSchedule(item.Id,Integer.ValueOf(item.Opportunity.ContractTerm__c),item.Opportunity.ContractStartDate__c,item.quantity*item.price__c));
    }
    else if(item.Product2.isOneTime__c){
      //1 monthly installment, 
      //N days after/before Contract StartDate
      Integer intDays = Integer.ValueOf(Label.helperScheduleOneTimes);
      listNewItemSchedules.addall(createOpportunityLineItemRevenueSchedule(item.Id,1,item.Opportunity.ContractStartDate__c.adddays(intDaysInRelationToContractStart),item.quantity*item.price__c));
    }
    //TODO find alternatives to deleting all existing schedules
    listOpportunityLineItemIds.add(item.Id);
  }
  //TODO try {} catch {}
  if (!listOpportunityLineItemIds.isEmpty()){
    delete [select Id from OpportunityLineItemSchedule where OpportunityLineItemId IN : listOpportunityLineItemIds];
  }
  if (!listNewItemSchedules.isEmpty()){
    //TODO try {} catch {} 
    insert listNewItemSchedules;
  } 
}

Josefine K. vermerkt im Code, wo es Verbesserungspotenzial gibt. Da aktuell aber noch kein Custom Exception Handling aufgebaut ist, läßt Josefine für Version 1 des Prototypen die Plattform mit Fehlern beim DML umgehen.

Außerdem hat Josefine den Abstand, zu dem die Einmal-Zahlungs-Produkte im Verhältnis zum VertragsStart eingeplant werden sollen, in ein Custom Label gespeichert, das sie im Code zu einer Anzahl von Tagen umwandelt. Aktuell ist Label.helperScheduleOneTimesauf -14 Tage eingestellt.

Die eigentliche invocableMethod ist nach diesen Vorbereitungen extrem kurz. Für jede Opportunity sucht sie alle Produkte mit den nötigen Informationen und läßt dann Schedules erstellen.

//SNIP FROM OpportunitiesService
@invocablemethod public static void handleOpportunityUpdateForRevenueSchedule(List<Id> listOpportunityIds){
calculateOpportunityLineItemsRevenueSchedule([SELECT
  Id,
  Quantity,
  Product2.isRecurring__c,
  Opportunity.ContractTerm__c,
  Opportunity.ContractStartDate__c,
  UnitPrice
  FROM OpportunityLineItem
  WHERE 
  OpportunityId IN : listOpportunityIds]);
}

Mehr Custom Fields

Josefine K. ist mit den ersten Testläufen sehr zufrieden. Die Schedules werden beim Anlegen korrekt erstellt. Negativ fällt ihr auf, daß sie den SalesPrice (UnitPrice) der Opportunity Produkte nicht mehr anpassen kann, sobald ein Schedule erstellt worden ist.

Das manuelle Löschen und editieren des Preises findet sie umständlich. Außerdem spiegelt der SalesPrice auf Grund von Salesforce Standard Verhalten bei aktivierten Revenue Schedules nicht mehr den monatlichen Abo-Preis wider.
Sie beschließt, ein neues Custom Field auf OpportunityProdukt anzulegen.

Price__c Currency, 16,2

Sie paßt ihre Klassen an und ersetzt OpportunityLineItem.UnitPrice mit OpportunityLineItem.Price__c wo nötig. (Hinweis: Das ist hier im Code nicht passiert).

Josefine K. widmet sich mit gemischten Gefühlen dem nächsten Schritt zu. Durch das neue Custom Field gilt es, den Prozess solide zu und fehlerfrei zu führen.

Der Prozess

Für OpportunityProdukte

Der Prozess soll zwei Fälle prüfen und jeweils tätig werden.

  1. beim Anlegen muß UnitPrice das neue Feld Price__cbefüllen.
  2. Verändert sich Price__c, müssen die Schedules neu berechnet werden

Kriterium Node 'created'

  • ISNEW()

Aktion

  • Update Price__c mit Wert von UnitPrice

Kriterium Node 'Change in Price [...]'

Neu berechnet werden soll bei neuen Items, oder wenn sich Preis oder Anzahl ändern.

ISCHANGED([OpportunityProduct].Quantity)```

Aktion

  • starte OpportunitiesService.handleOpportunityUpdateForRevenueSchedule mit der Liste zugehöriger OpportunityIds - die findet sich auch auf dem Opportunity Produkt.

Für Opportunity

Bei Opportunities gilt es, den Schedule der zugehörigen Produkte - sofern vorhanden - zu berechnen, wenn sich ContractStartDate__c oder die Laufzeit ändert.

Außerdem muß sichergestellt werden, daß der Vertragsstart nicht vor dem CloseDate liegt.

Der Prozess wird ferner rekursiv laufen, da sonst nicht allen Updates Rechnung getragen wird.

Kriterium Node 'Contract Start Date [...]'

[Opportunity].CloseDate > [Opportunity].ContractStartDate__c```

Aktion

  • Update ContractStartDate__c mit Wert von CloseDate

Kriterium Node 'Change in Term or StartDate [...]'

[Opportunity].HasOpportunityLineItem

####Aktion

- starte `OpportunitiesService.handleOpportunityUpdateForRevenueSchedule` mit der Liste zugehöriger `OpportunityIds`


Am Montag soll Josefine K. den Prototypen vorstellen. Sie könnte zufrieden sein. Egal, welche Kriterien hinzukommen, der Process ist schnell erweitert. Die Schedule Berechnung selbst in Apex ändert sich nicht, das ist ein großer Maintenance-Vorteil, versucht sich Josefine zu sagen. Tatsächlich ist sie nervös, wie sie es immer ist, we