OrderCloud: Working with Promotions

Reading Time: 10 minutes

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 PromotionDiscounts.

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.

PromotionCanCombineAcceptedOrder Promotions Applied
Promotion 1trueYesPromotion 1
Promotion 2trueYesPromotion 1, Promotion 2
Promotion 3falseNoPromotion 1, Promotion 2
Promotion 4trueYesPromotion 1, Promotion 2, Promotion 3
Promotion 5falseNoPromotion 1, Promotion 2, Promotion 3
Table 1: Example applying multiple CanCombine (true) promotions to an order.
PromotionCanCombineAcceptedOrder Promotions Applied
Promotion 3falseYesPromotion 3
Promotion 1trueNoPromotion 3
Promotion 2trueNoPromotion 3
Promotion 5falseNoPromotion 3
Promotion 4trueNoPromotion 3
Table 2: Example applying an exclusive CanCombine (false) promotion to an order.

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.

EndpointValidate / EvaluateCalculateCan Invalidate?
Add Promotion to OrderY1Y1N
Remove Promotion from OrderNNN
Line Item AddedY2YY
Line Item Updated (PUT / PATCH)Y2YY
Line Item DeletedNNY
Order Updated (PUT / PATCH)Y3YY
Calculate OrderYYN
Validate OrderYNN
Submit OrderYNN
Approve OrderYYN
Update Promotion (PUT / PATCH)NNY4
Delete PromotionNNN5
Delete Promotion AssignmentNNN4
Table 3: Overview of OrderCloud API promotion behaviour
  1. The promotion being added will be added to the existing order promotions for processing.
  2. 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.
  3. 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.
  4. 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.
  5. 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 ErrorReason
NotFound1Order or promotion was not found.
Promotion.OwnerIDMustMatchOrderToCompanyID1The order is being directly purchased from a suppiler, however the supplier is not the owner of the promotion.
Promotion.NotYetValidPromotion StartDate is later than current date/time.
Promotion.ExpiredPromotion ExpirationDate is earlier than current date/time.
Promotion.ExceedsUsageLimitPromotion RedemptionCount is greater than RedemptionLimit or user’s redemption count greater than RedemptionLimitPerUser.
Promotion.CannotCombineMore than one promotion has been applied to the order and one or more promotions have CanCombine set to false.
Promotion.AlreadyAdded1The promotion being added is already applied to the order.
Promotion.NotEligibleThe EligibleExpression has been evaluated to false.
Table 4: Overview of promotion-related API Errors.
  1. 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.

Promotionorder.TotalDiscount
$10 off order where order.Total > 9010010
10% off order where order.Total > 9010010
Result8020
Table 5: Total not including existing discounts (OrderCloud behaviour).

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 EligibleExpressions on subsequent promotions could have evaluated to false, resulting in them not being eligble.

Promotionorder.TotalDiscount
$10 off order where order.Total > 9010010
10% off order where order.Total > 9090Not Eligible
Result9010
Table 6: Example promotion validation/evaluation where the total includes existing discounts (which is not OrderCloud behaviour).

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.

Promotionorder.TotalDiscount
10% off order10010
$10 off order9010
Result8020
Table 7: Example promotion discount calculations where the total includes existing discounts (which is not OrderCloud behaviour).
Promotionorder.TotalDiscount
$10 off order10010
10% off order909
Result8119
Table 8: Example promotion discount calculations where the total includes existing discounts, calculated in a different order (which is not OrderCloud behaviour).

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.

  1. Create a promotion
    1. Set EligibleExpression as true so that always evaluates to true.
    2. Use xp to store the flag of the promotion custom logic name, e.g. promotion.xp.CustomLogicRule = 'MyRule'.
  2. Create a pre-hook webhook for the Add Promotion to Order endpoint.
  3. For the application receiving the payload (see figure 1):
    1. Call get order worksheet for order and line item models.
    2. Call get promotion for promotion model, if applicable.
    3. Validate the requested promotion requires custom logic and execute the custom logic to determine if the order is eligible.
    4. 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.
  4. In order to re-validate custom logic for flagged promotions, for any API call that triggers promotion validate / evaluate (from table 3):
    1. 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.
Figure 1: Example pre-hook webhook for implementing custom promotion logic.

References

OrderCloud: The Rules Engine

Reading Time: 4 minutes

In this article, we will review how OrderCloud leverages the rules engine to extend platform logic, including the resources currently backed by the engine and the syntax used to create rule-based expressions.

Overview

The rules engine is integrated into the OrderCloud platform as a means of extending platform logic via custom rule-based expressions. The expressions are defined in free-text properties using a custom syntax, consisting of OrderCloud models, operators and predefined functions, which are evaluated by the rules engine and used for “if-then” logic in the OrderCloud platform.

Below documents the resources that currently leverage the rules engine, along with high-level “if-then” logic.

ResourceIfThen
PromotionsEligibleExpressionCreate promotion discount of ValueExpression.
Order Approvals – Buyer Approval RulesRuleExpressionProgress through the order approval workflow.
Order Returns – Seller Approval RulesRuleExpressionProgress through the order returns workflow.

For example, the following promotion requirement has been translated into respective expressions.

10% off (up to $20) orders greater than $100 and containing product with ID ABC

EligibleExpressionorder.Total > 100 and items.any(ProductID = 'ABC')
ValueExpressionmax(order.Total * 0.1, 20)

Syntax

To ensure that you construct expressions correctly, the following syntax rules will need to met to avoid errors and unexpected behaviour.

  • Expressions use dot notation to navigate an object construct, e.g. order.xp.MyCustomProperty.
  • String values must be enclosed in straight single quotes ('), e.g. 'My Value'.
  • DateTime values must be enclosed in hashes (#), using the US date format, e.g. #6/24/2023#.
  • Parentheses (()) may be used to enclose sub-expressions and control order of execution, e.g. order.Total > 100 and (order.xp.MyProperty = 'this' or order.xp.MyProperty = 'that').

Operators

Operator TypeAvailable Options
Comparison=/==<>/!=, < , ><=>=
Logicalandor and not
Mathematical+-, */ and %

Models

While order and line item entities are the primary models in expressions, additional models can be accessed via child models. The following table shows how to access available models, which can also be identified throughout OrderCloud’s API reference documentation, including orders, line items, and both can be found under the order worksheet.

ModelSyntax
Orderorder.<property>
Line Itemitem.<property>
Productitem.Product.<property>
Product.<property>
in items.<function>(arg)
Variantitem.Variant.<property>
Variant.<property>
in items.<function>(arg)
User (Order Owner)order.FromUser.<property>
Billing Addressorder.BillingAddress.<property>
Shipping Addressitem.ShippingAddress.<property>
ShippingAddress.<property>
in items.<function>(arg)
Source Inventory/Seller/Supplier Locationitem.ShipFromAddress.<property>
ShipFromAddress.<property>
in items.<function>(arg)

Functions

Functions provide a means of evaluating specialised rules and conditions that are built into the rules engine. These pre-defined functions are documented below.

Items Functions

Items functions will have an implicit item context, within the function, therefore the item. prefix is omitted from any condition of the function argument.

FunctionReturnsDescription
items.any(arg)booleanReturns true if any line item meets the specified arg condition.
e.g. items.any(ProductID = 'ABC')
items.all(arg)booleanReturns true if all line item meets the specified arg condition.
e.g. items.all(ProductID = 'ABC')
items.quantity(arg)numberReturns the sum of line item quantities where the line item meets the specified arg condition.
e.g. items.quantity(ProductID = 'ABC') > 2
items.count(arg)numberReturns the number of line item where the line item meets the specified arg condition.
e.g. items.count(ProductID = 'ABC') > 2
items.total(arg)numberReturns the sum of all line item LineSubtotals where the line item meets the specified arg condition.
e.g. items.total(ProductID = 'ABC') > 100
product.incategory(args)booleanReturns true if the product for the line item being evaluated is assigned to one or more of the n category ID arguments.
e.g. items.any(product.incategory('cat1', cat2'))

Item Functions

The item functions along with any item specific condition can only be utilised in expressions for line item level promotions.

FunctionReturnsDescription
item.product.incategory(args)booleanReturns true if the product for the line item being evaluated is assigned to one or more of the n category ID arguments.
e.g. item.product.incategory('cat1', cat2')
item.incategory(args)booleanThe same as item.product.incategory(args), but with a shortened expression to save characters.
e.g. item.incategory('cat1', cat2')

Order Functions

The current order functions are limited to order approvals and usage should be limited to the order approvals – buyer approval rules resource to prevent any unexpected behaviour.

FunctionReturnsDescription
order.approved(ruleID)booleanReturns true if the ruleID has previously been approved for the order.
e.g. order.approved('rule1')

Order Return Functions

The current order return functions are limited to order returns, therefore usage should be limited to the order returns – seller approval rules resource to prevent any unexpected behaviour.

FunctionReturnsDescription
orderreturn.approved(ruleID)booleanReturns true if the ruleID has previously been approved for the order return.
e.g. orderreturn.approved('rule1')

General Functions

FunctionReturnsDescription
min(arg1, arg2)numberReturns the smaller of the two arg values.
e.g. min(order.LineItemCount, 5)
max(arg1, arg2)numberReturns the larger of the two arg values.
e.g. max(20, order.Total * 0.1)
now(arg)date/timeReturns a date/time with +/- arg value in days.
e.g. order.DateCreated < now(-5)

Limitations and Gotchas

As powerful as the rules engine can be, it’s not fool proof. If you are not careful with the way in which expressions are constructed, you may find yourself spending a lot of time troubleshooting unexpected behaviour due to a poorly written expression. The following limitations and gotchas will assist in mitigating both expression creation and troubleshooting efforts:

  • Expressions are limited to 400 characters.
  • Expressions do not support checking against null or whitespace values.
  • Arrays cannot be evaluated in expressions.
  • Inventory cannot be evaluated in expressions.
  • No validation against invalid result types for expressions. For example, the ValueExpression expects a decimal result, however the expression order.IsSubmitted = false would result to a boolean and return a 0 amount.
  • There is no way to extend or customise the rules engine directly, however leveraging webhooks it is possible for middleware to implement custom logic to fulfill any business requirements not covered by the rules engine.
  • Avoid creating conditions on properties that will change between order states as this could cause promotions to become invalidated and corrupt an order. For example, if using the condition order.Status = "Unsubmitted" where order approvals are active, when the order does get submitted, the order promotion will become invalidated.
  • Value expressions will be rounded to 2 decimal places, to the nearest number. This means that it’s possible that minor rounding issues could occur when applying multiple promotion calculations. For example, the following tables show two orders made with a line item level promotion to provide 5% each subtotal of each line item. As can be seen in the Totals row, the aggregate promotion discount has a +/-0.01 discrepancy.
UnitPriceQuantityLineSubtotal5% Promotion
Raw Value
5% Promotion
Rounded Value
9.9519.950.49750.50
9.9519.950.49750.50
9.9519.950.49750.50
Total329.851.49251.50
Order 1 promotion example.
UnitPriceQuantityLineSubtotal5% Promotion
Raw Value
5% Promotion
Rounded Value
9.95329.851.49251.49
Total329.851.49251.49
Order 2 promotion example.

References