Salesforce Revenue Cloud’s Summer ’25 release introduces a powerful new feature: Apex Hooks for Pricing Procedures. This is a game-changer for anyone needing to implement complex pricing logic, pass attribute values seamlessly, or integrate external data into their pricing calculations. Say goodbye to many previous limitations and hello to a new era of flexibility!
What Are Pricing Procedure Hooks?
In essence, pricing procedure hooks are custom Apex code snippets that you can execute at specific points during the pricing process. You have two main options:
- Prehooks: These Apex classes run before your standard pricing procedure kicks in. This is ideal for preparing data, performing initial calculations, or fetching external information that your pricing rules will depend on.
- Posthooks: These execute after your pricing procedure has completed. Use these to make final adjustments, update related records, or concatenate information for display purposes.
The beauty of this is the full power of Apex at your fingertips. You can interact with Sales Transaction Items (like Quote Lines or Order Lines) and their attributes, perform complex calculations, modify data in the transaction context, and even (with careful consideration for performance) make callouts to external systems.
Why Are Apex Hooks a Big Deal?
Previously, certain complex pricing scenarios were challenging or impossible to implement directly within the standard pricing procedure framework. Apex Hooks now bridge that gap by allowing you to:
- Calculate Complex Formulas: Implement intricate business logic that goes beyond standard rule capabilities.
- Pass Attribute Values to Lines: Easily transfer attribute selections made during configuration directly to fields on the corresponding quote or order lines.
- Integrate External Data: Fetch real-time data from other Salesforce objects or even external systems to influence pricing.
- Dynamic Updates: Modify data within the transaction context before or after the main pricing logic runs, ensuring accurate and dynamic results.
- Bundle Power: Maintain parent-child relationships within your code, allowing you to apply logic or carry attribute values down to product options (components) within a bundle.
How to Implement Pricing Procedure Hooks: A Step-by-Step Guide
Let’s walk through the process of setting up and using Apex Hooks in your Revenue Cloud environment (Summer ’25 or later).
Step 1: Enable Procedure Plan Orchestration and Exclude Default Procedures
First, you need to configure Revenue Cloud to utilize the new procedure plan framework for pricing:
- Navigate to Setup > Revenue Settings.
- Locate the Procedure Plan Orchestration for Pricing setting.
- Enable this setting. The description states: “Make pricing more efficient by enabling Salesforce to calculate pricing using procedure plans.”
- Next, find the setting Exclude Default and Sales Transaction Type Pricing Procedures.
- Enable this setting as well. The description explains: “When calculating pricing, exclude the default pricing procedure and any pricing procedure set for the sales transaction type of a quote or order. By excluding these pricing procedures, Salesforce will use only the pricing procedures specified in the procedure plan to calculate the pricing.”
By enabling both of these settings, you ensure that your custom Procedure Plans, along with any Apex Hooks they contain, will be the definitive source for pricing logic execution.
Step 2: Create Your Apex Class(es)
This is where your custom logic resides. You’ll need at least one Apex class (or two if you’re using both pre and post hooks).
Key Requirement: Your Apex class must implement the RevSignaling.SignalingApexProcessor interface. This interface has a single method, execute, which will contain your logic.
// Basic structure of an Apex Hook class
global class MyPricingPreHook implements RevSignaling.SignalingApexProcessor {
public RevSignaling.TransactionResponse execute(RevSignaling.TransactionRequest request) {
// Your pre-pricing logic here
// Access contextId: request.ctxInstanceId
// Use Context.IndustriesContext for querying and updating
RevSignaling.TransactionResponse response = new RevSignaling.TransactionResponse();
// Set response.status (SUCCESS or FAILED) and response.message
// Example:
// response.status = RevSignaling.TransactionStatus.SUCCESS;
// response.message = 'Pre-hook executed successfully.';
return response;
}
}
Within the execute method, you’ll use Context.IndustriesContext to query for SalesTransactionItem and SalesTransactionItemAttribute nodes based on the contextId from the TransactionRequest. You can then prepare and submit updates using industriesContext.updateContextAttributes().
Step 3: Create a Procedure Plan Definition
Procedure Plans orchestrate the steps in your pricing process.
- In Setup, search for “Procedure Plan Definitions” and click New.
- Set the following:
- Process Type: Revenue Cloud
- Object: Quote (or Order, or Both, depending on your needs)
- Context Definition: Select the active pricing procedure context definition you want this plan to use.
- Save your Procedure Plan Definition.
Step 4: Define Procedure Plan Sections
This is where you link your Apex classes and your standard pricing procedure.
- In your newly created Procedure Plan Definition, navigate to the “Procedure Plan Sections” related list.
- Click New for each section you need to add. You’ll typically create sections in this order:
- Pre-Hook Section (Optional):
- Enter a Section Name (e.g., “Pre-Pricing Apex Hook”).
- Section Type: Apex
- Apex Class: Select the Apex class you created for your pre-hook logic.
- Set the Order to determine its execution sequence (e.g., 10 for the first step).
- Pricing Procedure Section:
- Enter a Section Name (e.g., “Main Pricing Procedure”).
- Section Type: Pricing Procedure
- Pricing Procedure: Link to your existing pricing procedure that contains your standard pricing rules.
- Set the Order to run after the pre-hook (if used) (e.g., 20).
- Post-Hook Section (Optional):
- Enter a Section Name (e.g., “Post-Pricing Apex Hook”).
- Section Type: Apex
- Apex Class: Select the Apex class you created for your post-hook logic.
- Set the Order to run last (e.g., 30).
- Save each section.
- Finally, ensure your main Procedure Plan Definition is marked as Active.
Best Practices for Your Apex Hook Classes
Writing robust and efficient Apex is crucial, especially in a process as critical as pricing.
Validate Line/Attribute Status (Not Deleted):
Selective Querying: Only query the tags ('SalesTransactionItemAttribute', 'SalesTransactionItem') and attributes you absolutely need. The context can contain a lot of data; being specific improves performance. Filter attributes within your Apex logic after querying the main tags.
Null Checks and Graceful Error Handling: Always check for null values when accessing map keys, attribute values, or elements from lists (e.g., dataPath elements). Implement robust try-catch blocks around major logic sections, especially the context update call. Use the TransactionResponse to signal success or failure clearly.
Understand Data Paths (dataPath): The dataPath is crucial for targeting updates to the correct nodes.
Parent-Child Relationships & Attribute Propagation: When dealing with bundles, the LineItemPath tag on SalesTransactionItem is key. It often contains a path like ParentQLI_ID/ChildQLI_ID. The examples cleverly use this to determine the “source” QLI from which to fetch attribute values, ensuring that child/option lines can inherit or use values from their parent bundle.
String currentLineItemPathValue = (String)((Map<String, Object>)currentSalesItemTags.get('LineItemPath')).get('tagValue');
String attrSourceQliId;
if (currentLineItemPathValue != null && currentLineItemPathValue.contains('/')) {
attrSourceQliId = currentLineItemPathValue.split('/')[0]; // Get the parent/source QLI
} else {
attrSourceQliId = actualQliOrRefOfCurrentItem; // It's a parent or standalone item
}
Map<String, String> effectiveOrigAttrValues = qliToSourceAttributeValues.get(attrSourceQliId);
- Performance for Callouts: While callouts are technically possible if the Apex class is not directly part of the pricing transaction save (consult Salesforce documentation on transaction boundaries for hooks), be extremely mindful of their performance impact. Pricing calculations are often synchronous and user-facing. Long-running callouts can lead to poor user experience or even hit governor limits. If callouts are necessary:
- Ensure they are quick and optimized.
- Set appropriate timeouts.
- Test thoroughly under load.
- Consider using Platform Events or other asynchronous mechanisms for updates that don’t need to be strictly real-time within the pricing calculation if callouts are lengthy. The provided example PDF shows callouts but always test performance implications.
- Constants for Attribute Names and Magic Strings: Use
private static finalvariables for API names of fields (e.g.,PayloadCapacityRequirement__c), attribute names used in the context (e.g.,'Payload_Capacity_Requirement'), and any other fixed strings. This reduces typos and makes code maintenance significantly easier. The examples correctly use this pattern (e.g.,INPUT_ATTRIBUTES_FOR_CALC_AND_MAPPING,DESCRIPTION_FIELD_API_NAME). - Code Readability and Modularity: Break down complex logic into smaller, well-named private methods if your
executemethod becomes too long. Comment your code clearly, explaining the “why” behind logic, especially for complex transformations or data path manipulations.
Example Scenarios in Action
Based on the video walkthrough and provided code, here are a couple of practical examples:
- Pre-hook Example: Dynamic Calculation & Attribute Mapping (
ApexPreHookPayloadCapacity)- Dynamically calculates the “MaxPayload” attribute value based on several other input attributes (Payload_Capacity_Requirement, TerrainAdaptationLevel, MobilityType) using predefined factors in Apex Maps.
- Updates the “MaxPayload”
SalesTransactionItemAttributenode in the context. - Maps the values of source attributes (like ‘Operating_Mode’, ‘Payload_Capacity_Requirement’) and the newly calculated “MaxPayload” to corresponding custom fields on the
SalesTransactionItem(e.g., Quote Line’sOperatingMode__c,MaxPayload__c). - Correctly handles bundled products by ensuring that child/option lines receive the attribute values and calculated MaxPayload derived from their parent bundle.
- Includes a check to skip processing for lines marked as ‘DELETED’.
- Post-hook Example: Concatenating a Description (
ApexPostHookUpdateDescription)- After all pricing is calculated by the main pricing procedure, this hook runs.
- It queries the
SalesTransactionItemAttributenodes again to get the final values of specified attributes for each line (including ‘MaxPayload’, which might have been calculated by a pre-hook and further adjusted by pricing). - Concatenates these attribute values into a single, comprehensive description string, separated by ” – “.
- Updates a specified description field (e.g.,
SalesTrxnItemDescription) on theSalesTransactionItem(Quote Line) with this new concatenated string. - Also uses the
LineItemPathto correctly source attribute values for parent and child items.
Access the full code examples here : Apex Class for Prehook and Posthook examples or below
Apex PreHook Example
global class ApexPreHookPayloadCapacity implements RevSignaling.SignalingApexProcessor {
public virtual class BaseException extends Exception {}
public class OtherException extends BaseException {}
// Define maps for lookups for MaxPayload calculation
private static final Map<String, Decimal> BASE_CAPACITY_MAP = new Map<String, Decimal>{
'Light' => 10,
'Medium' => 20,
'Heavy' => 30
};
private static final Map<String, Decimal> TERRAIN_FACTOR_MAP = new Map<String, Decimal>{
'Low' => 1.0,
'Medium' => 0.9,
'High' => 0.8
};
private static final Map<String, Decimal> MOBILITY_FACTOR_MAP = new Map<String, Decimal>{
'Wheeled' => 1.0,
'Tracked' => 0.95,
'Legged' => 0.85
};
// Attributes to be read from SalesTransactionItemAttribute
private static final Set<String> INPUT_ATTRIBUTES_FOR_CALC_AND_MAPPING = new Set<String>{
'Payload_Capacity_Requirement',
'Operating_Mode',
'TerrainAdaptationLevel',
'MobilityType' // For MaxPayload calculation
};
private static final String DEBUG_VERSION = 'V5.6'; // Keep your original version or update as needed
public RevSignaling.TransactionResponse execute(RevSignaling.TransactionRequest request) {
System.debug('Executing PREHOOK - Update MaxPayload & Map to SalesItem Fields (Parent & Child) - ' + DEBUG_VERSION);
String contextId = request.ctxInstanceId;
Context.IndustriesContext industriesContext = new Context.IndustriesContext();
// STEP 1: Query SalesTransactionItemAttribute
Map<String, Object> inputQueryItemAttr = new Map<String, Object>{
'contextId' => contextId,
'tags' => new List<String>{'SalesTransactionItemAttribute'}
};
Map<String, Object> itemAttrQueryOutput = industriesContext.queryTags(inputQueryItemAttr);
Map<String, Object> itemAttrQueryResult = (Map<String, Object>) itemAttrQueryOutput.get('queryResult');
List<Object> itemAttrDataList = (List<Object>) itemAttrQueryResult.get('SalesTransactionItemAttribute');
System.debug(DEBUG_VERSION + ' - SalesTransactionItemAttribute Raw Query: ' + JSON.serializePretty(itemAttrQueryResult));
Map<String, Map<String, String>> qliToSourceAttributeValues = new Map<String, Map<String, String>>();
Map<String, List<Object>> qliToMaxPayloadAttrDataPath = new Map<String, List<Object>>();
if (itemAttrDataList != null) {
for (Object attrObj : itemAttrDataList) {
Map<String, Object> attrNode = (Map<String, Object>) attrObj;
Map<String, Object> tagMap = (Map<String, Object>) attrNode.get('tagValue');
List<Object> currentAttrDataPath = (List<Object>) attrNode.get('dataPath');
String attributeName = (tagMap.containsKey('Attribute')) ? (String)((Map<String, Object>)tagMap.get('Attribute')).get('tagValue') : null;
String attributeValueStr = (tagMap.containsKey('AttributeValue')) ? (String)((Map<String, Object>)tagMap.get('AttributeValue')).get('tagValue') : null;
String parentSalesTrxItemCtxId = (tagMap.containsKey('SalesTransactionItemAttrParent')) ? (String)((Map<String, Object>)tagMap.get('SalesTransactionItemAttrParent')).get('tagValue') : null;
if (parentSalesTrxItemCtxId == null) {
System.debug(DEBUG_VERSION + ' - ParentCtxId (QLI_ID) missing for attr: ' + attributeName);
continue;
}
if (!qliToSourceAttributeValues.containsKey(parentSalesTrxItemCtxId)) {
qliToSourceAttributeValues.put(parentSalesTrxItemCtxId, new Map<String, String>());
}
if (attributeName != null && INPUT_ATTRIBUTES_FOR_CALC_AND_MAPPING.contains(attributeName) && attributeValueStr != null) {
qliToSourceAttributeValues.get(parentSalesTrxItemCtxId).put(attributeName, attributeValueStr);
}
if (attributeName == 'MaxPayload' && currentAttrDataPath != null) {
qliToMaxPayloadAttrDataPath.put(parentSalesTrxItemCtxId, currentAttrDataPath);
}
}
}
System.debug(DEBUG_VERSION + ' - qliToSourceAttributeValues: ' + JSON.serializePretty(qliToSourceAttributeValues));
System.debug(DEBUG_VERSION + ' - qliToMaxPayloadAttrDataPath: ' + JSON.serializePretty(qliToMaxPayloadAttrDataPath));
// STEP 2: Query SalesTransactionItem nodes
Map<String, Object> inputQueryItem = new Map<String, Object>{'contextId' => contextId, 'tags' => new List<String>{'SalesTransactionItem'}};
Map<String, Object> salesItemQueryOutput = industriesContext.queryTags(inputQueryItem);
Map<String, Object> salesItemQueryResult = (Map<String, Object>) salesItemQueryOutput.get('queryResult');
List<Object> salesItemDataList = (List<Object>) salesItemQueryResult.get('SalesTransactionItem');
System.debug(DEBUG_VERSION + ' - SalesTransactionItem Raw Query: ' + JSON.serializePretty(salesItemQueryResult));
// STEP 3: Prepare updates
List<Map<String, Object>> allNodeUpdates = new List<Map<String, Object>>();
Map<String, String> sourceQliToCalculatedMaxPayload = new Map<String, String>();
Set<String> processedSourceQliForMaxPayloadAttrUpdate = new Set<String>();
if (salesItemDataList != null) {
// Pre-computation Loop: Calculate MaxPayload for source QLIs and queue their MaxPayload attribute updates
for (Object itemObj : salesItemDataList) {
Map<String, Object> salesItemNode = (Map<String, Object>) itemObj;
Map<String, Object> salesItemTags = (Map<String, Object>) salesItemNode.get('tagValue');
if (salesItemTags == null || !salesItemTags.containsKey('LineItemPath') || !salesItemTags.containsKey('LineItem')) {
continue;
}
String lineItemTagValue = (String)((Map<String, Object>)salesItemTags.get('LineItem')).get('tagValue');
String lineItemPathTagValue = (String)((Map<String, Object>)salesItemTags.get('LineItemPath')).get('tagValue');
String currentLoopItemQliIdOrRef = lineItemTagValue;
String qliForAttributeLookup;
if (lineItemPathTagValue != null && lineItemPathTagValue.contains('/')) {
qliForAttributeLookup = lineItemPathTagValue.split('/')[0];
} else {
qliForAttributeLookup = currentLoopItemQliIdOrRef;
}
if (qliToSourceAttributeValues.containsKey(qliForAttributeLookup) && !sourceQliToCalculatedMaxPayload.containsKey(qliForAttributeLookup)) {
Map<String, String> sourceAttrs = qliToSourceAttributeValues.get(qliForAttributeLookup);
String payloadCapReqVal = sourceAttrs.get('Payload_Capacity_Requirement');
String terrainAdaptVal = sourceAttrs.get('TerrainAdaptationLevel');
String mobilityTypeVal = sourceAttrs.get('MobilityType');
String calculatedMaxPayload = null;
Decimal baseCapacity = 0; Decimal terrainFactor = 1.0; Decimal mobilityFactor = 1.0;
if (payloadCapReqVal != null && BASE_CAPACITY_MAP.containsKey(payloadCapReqVal)) baseCapacity = BASE_CAPACITY_MAP.get(payloadCapReqVal);
if (terrainAdaptVal != null && TERRAIN_FACTOR_MAP.containsKey(terrainAdaptVal)) terrainFactor = TERRAIN_FACTOR_MAP.get(terrainAdaptVal);
if (mobilityTypeVal != null && MOBILITY_FACTOR_MAP.containsKey(mobilityTypeVal)) mobilityFactor = MOBILITY_FACTOR_MAP.get(mobilityTypeVal);
if (baseCapacity > 0) {
Decimal maxPayloadDecimal = baseCapacity * terrainFactor * mobilityFactor;
calculatedMaxPayload = String.valueOf(maxPayloadDecimal.setScale(2));
}
sourceQliToCalculatedMaxPayload.put(qliForAttributeLookup, calculatedMaxPayload);
System.debug(DEBUG_VERSION + ' - Calculated MaxPayload for source QLI ' + qliForAttributeLookup + ': ' + calculatedMaxPayload);
if (calculatedMaxPayload != null && !processedSourceQliForMaxPayloadAttrUpdate.contains(qliForAttributeLookup)) {
List<Object> sourceMaxPayloadAttrPathFull = qliToMaxPayloadAttrDataPath.get(qliForAttributeLookup);
if (sourceMaxPayloadAttrPathFull != null) {
List<Object> pathForAttrUpdate = new List<Object>(sourceMaxPayloadAttrPathFull);
if(!pathForAttrUpdate.isEmpty()) pathForAttrUpdate.remove(0);
Map<String, Object> maxPayloadAttrUpdateNode = new Map<String, Object>{
'nodePath' => new Map<String, Object>{'dataPath' => pathForAttrUpdate},
'attributes' => new List<Object>{ new Map<String, Object>{
'attributeName' => 'AttributeValue', 'attributeValue' => calculatedMaxPayload
}}
};
allNodeUpdates.add(maxPayloadAttrUpdateNode);
processedSourceQliForMaxPayloadAttrUpdate.add(qliForAttributeLookup);
System.debug(DEBUG_VERSION + ' - Queued Update for Source MaxPayload Attribute. QLI: ' + qliForAttributeLookup + ', Value: ' + calculatedMaxPayload);
} else {
System.debug(DEBUG_VERSION + ' - No existing "MaxPayload" attribute path found for source QLI: ' + qliForAttributeLookup);
}
}
}
}
// Main Update Loop: Apply field updates to all items
for (Object itemObj : salesItemDataList) {
Map<String, Object> salesItemNode = (Map<String, Object>) itemObj;
List<Object> salesItemDataPathFull = (List<Object>) salesItemNode.get('dataPath');
String currentItemNodePathId = (salesItemDataPathFull != null && !salesItemDataPathFull.isEmpty()) ? String.valueOf(salesItemDataPathFull.get(salesItemDataPathFull.size()-1)) : null;
Map<String, Object> currentSalesItemTags = (Map<String, Object>) salesItemNode.get('tagValue');
if (currentSalesItemTags == null || !currentSalesItemTags.containsKey('LineItemPath') || !currentSalesItemTags.containsKey('LineItem')) {
System.debug(DEBUG_VERSION + ' - LineItemPath or LineItem tag missing for item node with path ID: ' + currentItemNodePathId);
continue;
}
// **MODIFICATION START: Check DML Status**
String dmlStatusOfItem = null;
// Attempt to get dmlStatus from the LineItemPath tag, as observed in logs
Map<String, Object> lineItemPathTagDetails = (Map<String, Object>)currentSalesItemTags.get('LineItemPath');
if (lineItemPathTagDetails != null && lineItemPathTagDetails.containsKey('dmlStatus')) {
dmlStatusOfItem = (String)lineItemPathTagDetails.get('dmlStatus');
} else {
// Fallback or alternative: Check dmlStatus of LineItem tag if LineItemPath doesn't have it
Map<String, Object> lineItemTagDetails = (Map<String, Object>)currentSalesItemTags.get('LineItem');
if (lineItemTagDetails != null && lineItemTagDetails.containsKey('dmlStatus')) {
dmlStatusOfItem = (String)lineItemTagDetails.get('dmlStatus');
}
}
if ('DELETED'.equals(dmlStatusOfItem)) {
System.debug(DEBUG_VERSION + ' - Skipping update for DELETED SalesTransactionItem node with path ID: ' + currentItemNodePathId);
continue;
}
// **MODIFICATION END**
List<Object> salesItemPathForNodeUpdate = new List<Object>();
if(salesItemDataPathFull != null && salesItemDataPathFull.size() > 2) {
salesItemPathForNodeUpdate.add(salesItemDataPathFull.get(1));
salesItemPathForNodeUpdate.add(salesItemDataPathFull.get(2));
} else {
System.debug(DEBUG_VERSION + ' - Invalid dataPath for SalesItemFields update: ' + JSON.serialize(salesItemDataPathFull) + ' for item node ' + currentItemNodePathId);
continue;
}
String currentLineItemPathValue = (String)((Map<String, Object>)currentSalesItemTags.get('LineItemPath')).get('tagValue');
String actualQliOrRefOfCurrentItem = (String)((Map<String, Object>)currentSalesItemTags.get('LineItem')).get('tagValue');
String attrSourceQliId;
if (currentLineItemPathValue != null && currentLineItemPathValue.contains('/')) {
attrSourceQliId = currentLineItemPathValue.split('/')[0];
} else {
attrSourceQliId = actualQliOrRefOfCurrentItem;
}
Map<String, String> effectiveOrigAttrValues = qliToSourceAttributeValues.get(attrSourceQliId);
String effectiveCalculatedMaxPayload = sourceQliToCalculatedMaxPayload.get(attrSourceQliId);
if (effectiveOrigAttrValues == null) {
System.debug(DEBUG_VERSION + ' - No source attributes found for item node ' + currentItemNodePathId + ' (attrSourceQliId: ' + attrSourceQliId + ') for field updates.');
continue;
}
List<Map<String, Object>> salesItemFieldUpdates = new List<Map<String, Object>>();
if (effectiveOrigAttrValues.containsKey('Payload_Capacity_Requirement')) {
salesItemFieldUpdates.add(new Map<String, Object>{'attributeName' => 'PayloadCapacityRequirement__c', 'attributeValue' => effectiveOrigAttrValues.get('Payload_Capacity_Requirement')});
}
if (effectiveOrigAttrValues.containsKey('Operating_Mode')) {
salesItemFieldUpdates.add(new Map<String, Object>{'attributeName' => 'OperatingMode__c', 'attributeValue' => effectiveOrigAttrValues.get('Operating_Mode')});
}
if (effectiveOrigAttrValues.containsKey('TerrainAdaptationLevel')) {
salesItemFieldUpdates.add(new Map<String, Object>{'attributeName' => 'TerrainAdaptationLevel__c', 'attributeValue' => effectiveOrigAttrValues.get('TerrainAdaptationLevel')});
}
salesItemFieldUpdates.add(new Map<String, Object>{'attributeName' => 'MaxPayload__c', 'attributeValue' => effectiveCalculatedMaxPayload});
if (!salesItemFieldUpdates.isEmpty()) {
if(salesItemPathForNodeUpdate.size() == 2){
Map<String, Object> salesItemFieldsUpdateNode = new Map<String, Object>{
'nodePath' => new Map<String, Object>{'dataPath' => salesItemPathForNodeUpdate},
'attributes' => salesItemFieldUpdates
};
allNodeUpdates.add(salesItemFieldsUpdateNode);
System.debug(DEBUG_VERSION + ' - Queued SalesItem Fields Update for Item Node Path ID: ' + currentItemNodePathId + ' (using source QLI ' + attrSourceQliId + '), DataPath For Update: ' + JSON.serialize(salesItemPathForNodeUpdate));
} else {
System.debug(DEBUG_VERSION + ' - Invalid salesItemPathForNodeUpdate for Item Fields: ' + JSON.serialize(salesItemPathForNodeUpdate) + ' for item ' + currentItemNodePathId);
}
}
}
}
// STEP 4: Submit context update
RevSignaling.TransactionResponse response = new RevSignaling.TransactionResponse();
if (!allNodeUpdates.isEmpty()) {
Map<String, Object> updateInput = new Map<String, Object>{
'contextId' => contextId,
'nodePathAndAttributes' => allNodeUpdates
};
System.debug(DEBUG_VERSION + ' - Submitting Context Update: ' + JSON.serializePretty(updateInput));
try {
industriesContext.updateContextAttributes(updateInput);
response.status = RevSignaling.TransactionStatus.SUCCESS;
response.message = 'Pre-hook ' + DEBUG_VERSION + ': MaxPayload attribute and SalesItem fields updated for parents and children.';
} catch (Exception e) {
System.debug(DEBUG_VERSION + ' - ERROR during context update: ' + e.getMessage() + ' Stack: ' + e.getStackTraceString());
response.status = RevSignaling.TransactionStatus.FAILED;
response.message = 'Pre-hook ' + DEBUG_VERSION + ' Error: ' + e.getMessage();
}
} else {
response.status = RevSignaling.TransactionStatus.SUCCESS;
response.message = 'Pre-hook ' + DEBUG_VERSION + ': No updates were necessary.';
System.debug(DEBUG_VERSION + ' - No updates to submit.');
}
return response;
}
}
Apex PostHook Example
global class ApexPostHookUpdateDescription implements RevSignaling.SignalingApexProcessor {
public virtual class BaseException extends Exception {}
public class OtherException extends BaseException {}
// Define the set of attribute names to be concatenated for the description
private static final List<String> ATTRIBUTES_FOR_DESCRIPTION = new List<String>{
'Payload_Capacity_Requirement',
'Operating_Mode',
'TerrainAdaptationLevel',
'MobilityType',
'MaxPayload' // Assuming MaxPayload is also an attribute available at this stage
};
private static final String DEBUG_VERSION = 'V1.0_PostHookDesc';
private static final String DESCRIPTION_FIELD_API_NAME = 'SalesTrxnItemDescription'; // Or the correct API name for the description field
public RevSignaling.TransactionResponse execute(RevSignaling.TransactionRequest request) {
System.debug('Executing POSTHOOK - Update SalesTrxnItemDescription - ' + DEBUG_VERSION);
String contextId = request.ctxInstanceId;
Context.IndustriesContext industriesContext = new Context.IndustriesContext();
RevSignaling.TransactionResponse response = new RevSignaling.TransactionResponse();
List<Map<String, Object>> allNodeUpdates = new List<Map<String, Object>>();
try {
// STEP 1: Query SalesTransactionItemAttribute to get all relevant attribute values
Map<String, Object> inputQueryItemAttr = new Map<String, Object>{
'contextId' => contextId,
'tags' => new List<String>{'SalesTransactionItemAttribute'}
};
Map<String, Object> itemAttrQueryOutput = industriesContext.queryTags(inputQueryItemAttr);
Map<String, Object> itemAttrQueryResult = (Map<String, Object>) itemAttrQueryOutput.get('queryResult');
List<Object> itemAttrDataList = (List<Object>) itemAttrQueryResult.get('SalesTransactionItemAttribute');
System.debug(DEBUG_VERSION + ' - SalesTransactionItemAttribute Raw Query: ' + JSON.serializePretty(itemAttrQueryResult));
// Map to store attribute values per SalesTransactionItem (QLI)
// Key: parentSalesTrxItemCtxId (e.g., QLI Id)
// Value: Map of AttributeName -> AttributeValue
Map<String, Map<String, String>> qliToAttributeValues = new Map<String, Map<String, String>>();
if (itemAttrDataList != null) {
for (Object attrObj : itemAttrDataList) {
Map<String, Object> attrNode = (Map<String, Object>) attrObj;
Map<String, Object> tagMap = (Map<String, Object>) attrNode.get('tagValue');
String attributeName = (tagMap.containsKey('Attribute')) ? (String)((Map<String, Object>)tagMap.get('Attribute')).get('tagValue') : null;
String attributeValueStr = (tagMap.containsKey('AttributeValue')) ? (String)((Map<String, Object>)tagMap.get('AttributeValue')).get('tagValue') : null;
String parentSalesTrxItemCtxId = (tagMap.containsKey('SalesTransactionItemAttrParent')) ? (String)((Map<String, Object>)tagMap.get('SalesTransactionItemAttrParent')).get('tagValue') : null;
if (parentSalesTrxItemCtxId == null) {
System.debug(DEBUG_VERSION + ' - ParentCtxId (QLI_ID) missing for attribute: ' + attributeName);
continue;
}
if (!qliToAttributeValues.containsKey(parentSalesTrxItemCtxId)) {
qliToAttributeValues.put(parentSalesTrxItemCtxId, new Map<String, String>());
}
// Store the attribute if it's one we need for the description
if (attributeName != null && ATTRIBUTES_FOR_DESCRIPTION.contains(attributeName) && attributeValueStr != null) {
qliToAttributeValues.get(parentSalesTrxItemCtxId).put(attributeName, attributeValueStr);
}
}
}
System.debug(DEBUG_VERSION + ' - qliToAttributeValues: ' + JSON.serializePretty(qliToAttributeValues));
// STEP 2: Query SalesTransactionItem nodes to update their description
Map<String, Object> inputQueryItem = new Map<String, Object>{
'contextId' => contextId,
'tags' => new List<String>{'SalesTransactionItem'}
};
Map<String, Object> salesItemQueryOutput = industriesContext.queryTags(inputQueryItem);
Map<String, Object> salesItemQueryResult = (Map<String, Object>) salesItemQueryOutput.get('queryResult');
List<Object> salesItemDataList = (List<Object>) salesItemQueryResult.get('SalesTransactionItem');
System.debug(DEBUG_VERSION + ' - SalesTransactionItem Raw Query: ' + JSON.serializePretty(salesItemQueryResult));
if (salesItemDataList != null) {
for (Object itemObj : salesItemDataList) {
Map<String, Object> salesItemNode = (Map<String, Object>) itemObj;
List<Object> salesItemDataPathFull = (List<Object>) salesItemNode.get('dataPath');
Map<String, Object> salesItemTags = (Map<String, Object>) salesItemNode.get('tagValue');
if (salesItemDataPathFull == null || salesItemDataPathFull.size() < 3 || salesItemTags == null || !salesItemTags.containsKey('LineItem')) {
System.debug(DEBUG_VERSION + ' - Invalid dataPath or missing LineItem tag for a SalesTransactionItem node. Path: ' + JSON.serialize(salesItemDataPathFull));
continue;
}
// The dataPath for SalesTransactionItem is usually [ContextInstanceId, QuoteId, LineContextId]
// We need QuoteId and LineContextId for the nodePath update
List<Object> salesItemPathForNodeUpdate = new List<Object>{
salesItemDataPathFull.get(1), // QuoteId
salesItemDataPathFull.get(2) // LineContextId (0QL... or ref_...)
};
String currentItemQliIdOrRef = (String)((Map<String, Object>)salesItemTags.get('LineItem')).get('tagValue');
String lineItemPathTagValue = salesItemTags.containsKey('LineItemPath') ? (String)((Map<String, Object>)salesItemTags.get('LineItemPath')).get('tagValue') : currentItemQliIdOrRef;
// Determine the source QLI ID for attribute lookup (handles parent/child relationships)
String attrSourceQliId;
if (lineItemPathTagValue != null && lineItemPathTagValue.contains('/')) {
attrSourceQliId = lineItemPathTagValue.split('/')[0];
} else {
attrSourceQliId = currentItemQliIdOrRef;
}
if (qliToAttributeValues.containsKey(attrSourceQliId)) {
Map<String, String> itemAttributes = qliToAttributeValues.get(attrSourceQliId);
List<String> descriptionParts = new List<String>();
// Concatenate attribute values in the specified order
for (String attrName : ATTRIBUTES_FOR_DESCRIPTION) {
if (itemAttributes.containsKey(attrName) && itemAttributes.get(attrName) != null) {
descriptionParts.add(itemAttributes.get(attrName));
} else {
descriptionParts.add(''); // Add an empty string if an attribute is missing to maintain structure or a placeholder
System.debug(DEBUG_VERSION + ' - Attribute ' + attrName + ' not found or null for QLI: ' + attrSourceQliId);
}
}
String newDescription = String.join(descriptionParts, ' - '); // Join with a separator
if (String.isNotBlank(newDescription)) {
Map<String, Object> descriptionUpdateNode = new Map<String, Object>{
'nodePath' => new Map<String, Object>{'dataPath' => salesItemPathForNodeUpdate},
'attributes' => new List<Object>{
new Map<String, Object>{
'attributeName' => DESCRIPTION_FIELD_API_NAME,
'attributeValue' => newDescription
}
}
};
allNodeUpdates.add(descriptionUpdateNode);
System.debug(DEBUG_VERSION + ' - Queued description update for item (QLI Ref: ' + currentItemQliIdOrRef + ', Attr Source QLI: ' + attrSourceQliId + '). New Description: ' + newDescription + '. DataPath for Update: ' + JSON.serialize(salesItemPathForNodeUpdate));
} else {
System.debug(DEBUG_VERSION + ' - Generated description is blank for item (QLI Ref: ' + currentItemQliIdOrRef + ', Attr Source QLI: ' + attrSourceQliId + '). No update queued.');
}
} else {
System.debug(DEBUG_VERSION + ' - No attributes found for QLI: ' + attrSourceQliId + ' to build description for item (QLI Ref: ' + currentItemQliIdOrRef + ').');
}
}
}
// STEP 3: Submit context update if there are any changes
if (!allNodeUpdates.isEmpty()) {
Map<String, Object> updateInput = new Map<String, Object>{
'contextId' => contextId,
'nodePathAndAttributes' => allNodeUpdates
};
System.debug(DEBUG_VERSION + ' - Submitting Context Update: ' + JSON.serializePretty(updateInput));
industriesContext.updateContextAttributes(updateInput);
response.status = RevSignaling.TransactionStatus.SUCCESS;
response.message = 'Post-hook ' + DEBUG_VERSION + ': SalesTrxnItemDescription updated successfully.';
} else {
response.status = RevSignaling.TransactionStatus.SUCCESS;
response.message = 'Post-hook ' + DEBUG_VERSION + ': No description updates were necessary.';
System.debug(DEBUG_VERSION + ' - No updates to submit.');
}
} catch (Exception e) {
System.debug(DEBUG_VERSION + ' - ERROR during Post-hook execution: ' + e.getMessage() + ' Stack: ' + e.getStackTraceString());
response.status = RevSignaling.TransactionStatus.FAILED;
response.message = 'Post-hook ' + DEBUG_VERSION + ' Error: ' + e.getMessage();
// Optionally, rethrow a custom exception if needed for more specific error handling upstream
// throw new OtherException(response.message);
}
return response;
}
}
Looking Ahead
Apex Hooks in Revenue Cloud pricing procedures open up a vast array of possibilities for tailoring the pricing engine to meet even the most unique business requirements.
By understanding how to set them up and following best practices in your Apex development, you can significantly enhance the power and flexibility of your Salesforce Revenue Cloud implementation.
If you have questions or need assistance implementing Apex Hooks in your environment, don’t hesitate to reach out!
Comments are closed.