In this article, we will review how the OrderCloud platform applies promotion discounts on an order and its line items, identify when promotions are validated, calculated, and invalidated, and approaches to working with promotions to ensure robust implementations through error handling and extending promotions.
Introduction
The promotions resource is one of the few OrderCloud resources that is currently backed by the rules engine, with each promotion utilising two rule expressions. The EligibleExpression
is a boolean expression, which will evaluate whether an order is eligible to have the promotion applied, and a ValueExpression
, which evaluates the order and returns a monetary value, which is then used in determining the order and line level PromotionDiscount
s.
Promotions that have been applied to orders are referred to as order promotions, which contain static a discount value Amount
and reference data for the underlying promotion, which can allow a promotion entity to be updated and impact existing order promotions upon re-evaluation.
Order Level vs Line Item Level Promotions
Order Level Promotions
With the LineItemLevel
promotion property set to false
, the promotion intends to calculate the order promotion’s Amount
(discount) to be applied to the order’s PromotionDiscount
, aggregated with all other promotion discounts.
Expressions written for order level promotions can target properties within the order and line items models, however line items require are targeted specifcally via the items functions, e.g. order.Subtotal > 100 and items.any(ProductID = 'ABC')
.
In the following example, the order worksheet shows the calculated line item level order promotions being applied to the order’s PromotionDiscount
, affecting the Total
, which also contains line level discounts.
{ "Order": { "ID": "OrderLevelPromotionOrder", ... "Subtotal": 100, "PromotionDiscount": 40, // (25 + 15) order level discounts "Total": 60, ... }, "OrderPromotions": [ { "Amount": 25, "LineItemID": null, "ID": "promo1", "LineItemLevel": false, "Code": "promo1", "EligibleExpression": "order.ID = 'OrderLevelPromotionOrder'", "ValueExpression": "25", ... }, { "Amount": 15, "LineItemID": null, "ID": "promo2", "LineItemLevel": false, "Code": "promo2", "EligibleExpression": "true", "ValueExpression": "15", ... }, ... ], ... }
Line Item Level Promotions
Alternatively, promotions can be configured as a line item level promotion by setting the LineItemLevel
property to true
. Line item level promotions are used to apply the promotion discount to specific line items that meet the rule configured under the EligibleExpression
. While the order model can be targeted within the expressions, the line item are evaluated individually with their own context and do not have any knowledge of each other. The order promotion’s Amount
will be aggregated to the line item’s PromotionDiscount
as well as the order’s PromotionDiscount
.
To target the line items, the item.<property>
syntax and the item functions are used, e.g. item.ProductID = 'ABC' or item.incategory('category1')
.
In the following example, the order worksheet shows the calculated line item level order promotions being applied to the order and line items’ PromotionDiscount
, affecting the Total
and LineTotal
properties.
{ "Order": { "ID": "LineItemLevelPromotionOrder", ... "Subtotal": 200, "PromotionDiscount": 55, // 30 (line item level) + 25 order level "Total": 145, ... }, "LineItems": [ { "ID": "LineItemID1", "ProductID": "ABC" ... "PromotionDiscount": 30, // (20 + 10) line item level discounts "LineTotal": 70, "LineSubtotal": 100, ... } ], "OrderPromotions": [ { "Amount": 20, "LineItemID": "LineItemID1", "ID": "promo2", "LineItemLevel": true, "Code": "promo2", ... "EligibleExpression": "item.incategory('category1')", "ValueExpression": "item.LineSubtotal * .2", "CanCombine": true, ... }, { "Amount": 10, "LineItemID": "LineItemID1", "ID": "promo3", "LineItemLevel": true, "Code": "promo3", ... "EligibleExpression": "item.ProductID = 'ABC'", "ValueExpression": "10", "CanCombine": true, ... }, ... ], ... }
The Can Combine Exclusivity Flag
The promotion’s CanCombine
flag dictates how promotions can be applied to an order. With the flag set to true
the promotion can be applied to an order along with other promotions that have the CanCombine
flag also set to true
. When the flag is set to false
, it is treated as an exclusive promotion that can only be added to an order in isolation.
The initial promotion that has been applied to an order determines whether additional promotions can be applied to an order.
In table 1 and table 2, we see the results of applying five promotions consecutively, with a combination of CanCombine
flags set to true
and false
. Table 1 initially applies a promotion that can be combined with other promotions and therefore results in multiple promotions being applied to the order with the exclusive (CanCombine
is false
) promotions being rejected. Conversely, table 2 shows that by applying an exclusive promotion first, no other promotions can be added to the order.
Promotion | CanCombine | Accepted | Order Promotions Applied |
---|---|---|---|
Promotion 1 | true | Yes | Promotion 1 |
Promotion 2 | true | Yes | Promotion 1, Promotion 2 |
Promotion 3 | false | No | Promotion 1, Promotion 2 |
Promotion 4 | true | Yes | Promotion 1, Promotion 2, Promotion 3 |
Promotion 5 | false | No | Promotion 1, Promotion 2, Promotion 3 |
Promotion | CanCombine | Accepted | Order Promotions Applied |
---|---|---|---|
Promotion 3 | false | Yes | Promotion 3 |
Promotion 1 | true | No | Promotion 3 |
Promotion 2 | true | No | Promotion 3 |
Promotion 5 | false | No | Promotion 3 |
Promotion 4 | true | No | Promotion 3 |
API Promotion Logic Overview
Promotion Validation, Calculation, and Invalidation
In working with the promotions resource of the OrderCloud platform, understanding how internal promotion logic impacts an order will help identify where middleware and front end applications should be expecting to handled errors.
There are three aspects to promotions that need to be considered:
- Validate / Evaluate: Whether or not an endpoint will trigger validation/evaluation of existing promotions.
These endpoints may throw API errors for invalidated promotions, which could occur due to any of the following reasons listed below in Can Invalidate? as well as a promotion expiring expiring due to lapsed time. - Calculate: Whether or not an endpoint will attempt to calculate the current value of all order promotions. Where the promotion amount has been overridden from the Calculate Order integration event, these are considered frozen and will not be updated.
- Can Invalidate?: Whether or not an endpoint can cause order promotions to be in an invalid state. This occurs as the OrderCloud platform does not validate the order promotions after its transaction, and therefore will not be able to inform the calling system of the possible invalidation in the API response. Invalidation may occur from:
- An order has been updated or a line item has been added/updated/removed, resulting in the order no longer meeting the eligible expression criteria.
- A promotion has been updated by a business user, e.g. eligible expression, start date, end date, etc., which would no longer be valid for an order and/or its line items.
With these aspects of promotion logic defined, table 3 documents all endpoints relevant to promotions and promotion invalidation.
Endpoint | Validate / Evaluate | Calculate | Can Invalidate? |
---|---|---|---|
Add Promotion to Order | Y1 | Y1 | N |
Remove Promotion from Order | N | N | N |
Line Item Added | Y2 | Y | Y |
Line Item Updated (PUT / PATCH) | Y2 | Y | Y |
Line Item Deleted | N | N | Y |
Order Updated (PUT / PATCH) | Y3 | Y | Y |
Calculate Order | Y | Y | N |
Validate Order | Y | N | N |
Submit Order | Y | N | N |
Approve Order | Y | Y | N |
Update Promotion (PUT / PATCH) | N | N | Y4 |
Delete Promotion | N | N | N5 |
Delete Promotion Assignment | N | N | N4 |
- The promotion being added will be added to the existing order promotions for processing.
- Although the line item models are updated in memory, the promotion validation occurs against the database models, therefore even if an changes to an order line items would make an ineligible promotion eligible after the transaction, the API will still throw a
Promotion.NotEligible
API error. - The order model is updated in memory prior of promotion validation, allowing an order with ineligible promotions to be resolved with the order update request.
- Promotion validation relating to promotion assignments only occurs during the Add Promotion to Order endpoint and is not validated for subsequent validation measures, therefore the promotion may continue to apply and calculate for the order if the promotion assignment has been removed.
- Deleted promotions are removed directly from the database, which will remove the deleted promotion from any unsubmitted order or null out the promotion ID of a submitted order.
Promotion-Related API Errors
When the OrderCloud API validates / evaluates order promotions, table 4 documents the possible API errors that can be returned in the API Response that the implementer may want to handle as a global catch all or have error handling built for specific API errors.
API Error | Reason |
---|---|
NotFound 1 | Order or promotion was not found. |
Promotion.OwnerIDMustMatchOrderToCompanyID 1 | The order is being directly purchased from a suppiler, however the supplier is not the owner of the promotion. |
Promotion.NotYetValid | Promotion StartDate is later than current date/time. |
Promotion.Expired | Promotion ExpirationDate is earlier than current date/time. |
Promotion.ExceedsUsageLimit | Promotion RedemptionCount is greater than RedemptionLimit or user’s redemption count greater than RedemptionLimitPerUser . |
Promotion.CannotCombine | More than one promotion has been applied to the order and one or more promotions have CanCombine set to false . |
Promotion.AlreadyAdded 1 | The promotion being added is already applied to the order. |
Promotion.NotEligible | The EligibleExpression has been evaluated to false . |
- Add Promotion to Order endpoint only.
Promotion Calculation Logic
For promotion calculation logic, when evaluating expressions against the order’s Total
or line item’s LineTotal
, the totals are not updated with any promotion discounts previously applied/calculated. This is to prevent one order’s promotion invalidating or incorrectly calculating subsequent order promotions.
Table 5 shows an example of two promotions calculated by the OrderCloud platform when applied to an order.
Promotion | order.Total | Discount |
---|---|---|
$10 off order where order.Total > 90 | 100 | 10 |
10% off order where order.Total > 90 | 100 | 10 |
Result | 80 | 20 |
To see what would happen if the totals were updated per promotion calculation, let’s look at the following scenarios.
If the promotion discounts were updated on a running order total, table 6 shows that EligibleExpression
s on subsequent promotions could have evaluated to false, resulting in them not being eligble.
Promotion | order.Total | Discount |
---|---|---|
$10 off order where order.Total > 90 | 100 | 10 |
10% off order where order.Total > 90 | 90 | Not Eligible |
Result | 90 | 10 |
Similarly, if we focus on the promotion discount values alone, we see that if OrderCloud were to use a dynamic total that included the previous discounts then the results could be affected by the order they are evaluated in, which is represented in table 7 and table 8.
Promotion | order.Total | Discount |
---|---|---|
10% off order | 100 | 10 |
$10 off order | 90 | 10 |
Result | 80 | 20 |
Promotion | order.Total | Discount |
---|---|---|
$10 off order | 100 | 10 |
10% off order | 90 | 9 |
Result | 81 | 19 |
Promotion Prioritisation Logic
When the OrderCloud platform is evaluating an order with multiple order promotions, regardless of whether the promotions are order level or line item level, there are currently no smarts to prioritise one promotion over another. As a result, there’s no guarantee that order promotions will always be evaluated in a consistent order. With the promotion calculation logic not rolling discounts into the order and line totals, this should not affect the outcome of the final order and line item discount values.
Extending Promotion Functionality
Handling Promotion Invalidation
From table 3, we now have an understanding of the API endpoints will trigger order promotion validation, in turn revealing invalidated order promotions, which our middleware and front end applications would ideally handle gracefully, but requires custom implementation to achieve.
In the following example, we take the approach of wrapping any OrderCloud endpoint, which will validate/evaluate the order promotions, in a try catch and by creating logic specifically for handling a promotion that is no longer eligible our calling system can silently resolve these issues and then attempt to call the intended endpoint again, without the previous API error.
As there could be multiple API errors that follow up on subsequent calls, we allow for multiple retries, however this is just a guide that would likely be amended to cater for the specific business requirements of any given solution.
Promotion Evaluation Endpoint Wrapper
var hasError = false;
var retryCount = 0;
do
{
try
{
hasError = false;
await orderCloudClient.<Evaluate Endpoint>(); // Add Promotion to Order, Line Item Added, etc.
}
catch (OrderCloudException ex)
{
hasError = true;
retryCount++;
if (ex.HttpStatus == HttpStatusCode.BadRequest &&
ex.Errors.Any(error =>
error.ErrorCode == OrderCloud.SDK.ErrorCodes.Promotion.NotEligible))
{
var codes = ex.Errors.Select(error => (error.Data as Promotion)?.Code);
// Resolve all promotion errors, e.g. remove promotions,
// record removed promotions to notify the user, etc.
}
}
} while (hasError && retryCount < 3);
Overriding Line Item Promotion Discounts
It’s possible that the way a business handles line item discounts differs to the calculation logic of OrderCloud and the rules engine, e.g. promotion discounts may be rounded or weighted across line items, which can be supported through the use of the order calculate order checkout integration event.
The calculate integration event provides the ability to create LineItemOverrides.PromotionOverrides
, which supports overriding promotion amounts. The overriden Amount
will be applied to the respective order promotion Amount
and a hidden frozen
flag will be set to true to prevent any future order promotion calculations from reverting back to the value calculated from the ValueExpression
.
{ "Order": { "ID": "LineItemLevelPromotionOrder", ... "Subtotal": 200, "PromotionDiscount": 39.95, // 19.95 (line item level) + 20 order level "Total": 160.05, ... }, "LineItems": [ { "ID": "LineItemID1", ... "PromotionDiscount": 19.95, // (9.95 + 10) line item level discounts "LineTotal": 80.05, "LineSubtotal": 100, ... } ], "OrderPromotions": [ { "Amount": 9.95, "LineItemID": "LineItemID1", "ID": "promo2", "LineItemLevel": true, "Code": "promo2", ... "EligibleExpression": "item.incategory('category1')", "ValueExpression": "item.LineSubtotal * .2", "CanCombine": true, ... }, ... ], "OrderCalculateResponse": { "LineItemOverrides": [ { "LineItemID": "LineItemID1", "PromotionOverrides": [ { "PromotionID": "promo2", "Amount": 9.95 } ], "Remove": null } ], "HttpStatusCode": 200, "Succeeded": true }, ... }
To remove a line item promotion’s override, the order calculate integration event will need explicitly return the respective LineItemOverrides
item with the Remove
property set to true
. Omitting it from the request will not remove the hidden frozen
flag, meaning the overrides will still be applied to the order promotions and will not be calculated from the ValueExpression
.
Implementing the Calculate Integration Event
Where promotion line item overrides are required in a solution, it is likely that you would need to call the integration event immediately after any API endpoint to calls the calculate endpoint in order to ensure that discounts are kept in sync as per the following code snippet.
await orderCloudClient.<Calculate Endpoint>(); // Add Promotion to Order, Line Item Added, etc.
await orderCloudClient.IntegrationEvents.CalculateAsync(OrderDirection.Outgoing, orderID);
Custom Promotion Logic
While OrderCloud does not support creating custom functions for the rules engine’s expression evaluation, we have to look for alternate paths achieve custom promotion logic in an OrderCloud solution.
Copy Data to Order or Line Item xp
If the order or line item models are lacking the object models for creating expression queries against, e.g. selected shipping methods, then the first approach is to copy data onto the order or line item xp
. This may be considered xp
abuse, but a necessary evil, especially for calculated and transient data.
In taking this approach, you will also be responsible for maintaining the data integrity of this duplicate data to ensure it does not become stale when the platform validates order promotions during API endpoint requests listed in table 3.
With the introduction of premium order search, do not forget that xp
property data types must be consistent across all orders to prevent the index from breaking.
Pre-Hook Webhooks
When needing to replace a promotion’s EligibleExpression
with custom promotion logic, webhooks can be leveraged to intercept the Add Promotion to Order requests with the following high-level approach.
- Create a promotion
- Set
EligibleExpression
astrue
so that always evaluates to true. - Use xp to store the flag of the promotion custom logic name, e.g.
promotion.xp.CustomLogicRule = 'MyRule'
.
- Set
- Create a pre-hook webhook for the Add Promotion to Order endpoint.
- For the application receiving the payload (see figure 1):
- Call get order worksheet for order and line item models.
- Call get promotion for promotion model, if applicable.
- Validate the requested promotion requires custom logic and execute the custom logic to determine if the order is eligible.
- Set the webhook response
proceed
property with the eligibility state of the order promotion, to inform the OrderCloud API whether to apply the promotion or not.
- In order to re-validate custom logic for flagged promotions, for any API call that triggers promotion validate / evaluate (from table 3):
- All flagged promotions would need to be removed and then re-added to trigger the pre-hook custom validation again, prior to calling the evaluate endpoint.