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
, DateContractTerm__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.helperScheduleOneTimes
auf -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.
- beim Anlegen muß
UnitPrice
das neue FeldPrice__c
befüllen. - Verändert sich
Price__c
, müssen die Schedules neu berechnet werden
Kriterium Node 'created'
ISNEW()
Aktion
- Update
Price__c
mit Wert vonUnitPrice
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örigerOpportunityIds
- 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 vonCloseDate
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