Promotion Algorithms

August 4, 2023


1. Loyalty in General

Promotions are part of a larger loyalty system. Promotions can be combined with, and are in some cases interchangeable with price lists. A shop may issue and accept coupons; coupons are associated with promotions and give discounts according to a promotion. Customer can pay for their purchase with gift cards and store credit.

A "promotion" in ERPLY's terminology is a conditional discount: it only applies when a certain condition is met:

  • The total value of customer's shopping cart exceeds a threshold;
  • Or the shopping cart contains a specific item;
  • Or the customer has loyalty points to spend.

In contrast, price lists usually apply unconditionally, ie. regardless of the shopping cart. This distinction may help to decide whether to implement a discount as a promotion or a price list.

A promotion can be made unconditional if needed (eg. if it should be a manually applied promotion). To achieve this, set the condition to "Customer's basket value" to $0.01.

2. Promotions in Back Office / API

There is only one implementation of promotions, and this is contained in API: API call applyPromotions, alias calculateShoppingCart.

Backend is only for defining the promotions and coupon rules, but it does not apply the promotions itself.

The new Berlin back office UI uses the "calculateShoppingCart" API call for sales invoices, although only for applying price lists and calculating invoice totals. (It sends a flag to ignore any promotions). Compared to POS where each sale is generally done quickly and in one flow, it is much harder to implement promotions in back office: invoices may be saved and edited multiple times, promotions expire and new ones are created, but user may not want the prices to change on their own.

3. Promotions and price lists - order of processing

In general, price lists, discounts and promotions are applied by the API in the following order:

1. Price lists. Several price lists may apply to both the location and the customer, and price lists may overwrite each other in various ways.

If the resulting price is different from product card price, calculateShoppingCart by default does not show nor interpret it as a discount. So, if product card price is $10 and price list price is $5, API will return that item price is just $5. It will not be shown as a 50% discount. Trying to change this behavior with configuration parameter "product_price_discount_basis" = "product_card" is not recommended, as this will create issues for manual discounts in POS.

If you have specified the "price" parameter in calculateShoppingCart input, it overwrites price list price.

2. Manual discount. If you have specified a discount percentage, it will be further subtracted from the price list price.

3. Promotions.

This order — manual discount before promotions — was requested by customers. The reason is that when a manual discount is combined with a coupon "$50 off entire invoice", they want to see that the coupon subtracts exactly $50. If the manual discount were at the end, this would no longer be the case.

For example: a $200 sale with manual 10% discount would be $180; the same sale with a $50 coupon, FOLLOWED by 10% manual discount would be $135. The difference between these two cases was $45, which looked incorrect from customer's perspective.

There is one exception to this order. After a "Get X pcs. of product Y for total price Z" promotion or "Special Unit Price" promotion (both of which rewrite the price, instead of applying a discount), manual discount is re-applied, so that it would not be lost.

4. Promotion conceptual model

Promotions can be 

  • Automatically applied
  • Applied by cashier manually
  • Applied by entering/scanning the serial number of a coupon. 

A printed coupon is associated with a "coupon rule" and the coupon rule tells which promotion to apply when the coupon is scanned.

A coupon must be used only once. API calculateShoppingCart tells which of the scanned coupons actually applied to the sale and gave a discount. When the sale is completed, it is the responsibility of the POS to call API "redeemIssuedCoupon" to cancel the used coupons.

It is also possible to define a promotion that only applies if a certain special condition is met:

  • One-time promotion: promotion that can apply only once for each customer, ever.
  • Birthday promotion: Promotion that only applies on customer's birthdays.
  • One-time birthday promotion: promotion that only applies on customer's birthday, but can only apply once every year.

These special promotions can also be automatic, manual on coupon-triggered. Eg. a birthday promo cannot apply on any other day, even if I select it manually in POS or scan a coupon.

A promotion has two parts:

  • a condition / trigger;
  • the effect, or the discount that customer gets when the condition is fulfilled.

5. Triggers / Conditions

  • Spend at least X $
  • Purchase X items from group
  • Purchase X items from category
  • Purchase X items from manually defined list
  • Exchange loyalty points for a discount

Spend at least X $:

Total value of the basket (excluding the item that is just about to receive the discount) must be at least X. In the United States, this must be the net total; in Europe, this must be the total with VAT. (Same logic applies everywhere else where we consider prices or invoice totals: net in US, with VAT in Europe)

Purchase X items from group / category / list of products: 

"Mixing" is allowed, meaning that if the promo requires 2 items, they can be different ones, as long as they are from the same group / both belong to the list.

Exchange loyalty (reward) points for a discount:

This trigger can be currently combined only with the following effects:

  • $ discount from the whole invoice
  • % discount from the whole invoice
  • % discount off of a specific product (from a group, category, or a product list)

It is further possible to define how many times this promo should apply:

  • Once
  • As many times as customer's point balance allows
  • Up to a maximum discount % (works only with the "whole invoice" discounts!)

It is recommended to set this promo up with a small step (eg. $0.01 discount for 1 point). This way, the promotion will work most naturally. Of course, reward point earning rate must then also be set high enough (ie., a point should not be too "expensive").

6. Effects / awards 

  • Get % off of all items
  • Get % off of all items except excluded items
  • Get % off of items from manually defined list
  • Get $ off the entire purchase
  • Get $ off the entire purchase except excluded items
  • Get $ off of items from manually defined list
  • Get purchased items for special price (X items for $)
  • Get $ or % off each item if you buy X or more of specific products
  • Get % or $ off for special amount (or unlimited) of items from group
  • Get % or $ off for special amount (or unlimited) of items from category
  • Get % or $ off for special amount (or unlimited) of items from manually defined list
  • Get % or $ off for special amount (or unlimited) of cheapest items from group
  • Get % or $ off for special amount (or unlimited) of cheapest items from category
  • Get % or $ off for special amount (or unlimited) of cheapest items from manually defined list

7. Blocking

Promotion algorithm uses a concept of "blocking".

It keeps track of which items in the shopping cart have triggered a promotion, or which items have been discounted by a promotion. Each such item gets "blocked", meaning that it can no longer trigger any other promotions, nor receive a discount.

If you have a promotion "Buy keyring and get Coke 50% off", and customer buys a keyring and a Coke, the discount applies and both items get "blocked". Even if there is another promotion, for example "Buy a Coke and get 50% off the sale", it will not be applied. To get this promotion to apply, customer will have to buy another bottle of Coke.

The following shopping cart items are blocked from the beginning:

  • Items with a fractional quantity. (If you buy 3.5 pcs, then only 3 items will participate in promotions, the last half will not.)
  • Items with negative quantity. (Promotions are not applied on returns.)
  • Items for which a fixed price has been manually set in POS. Currently, setting a fixed price to an invoice row not only overwrites price list price, but also blocks the row from receiving any item-level discounts

Only an item-level discount "blocks" the item that received it. An "invoice-level" discount (eg. 10% off of everything) will not.

8. Stacking promotions

In certain cases, it might be desirable to allow the promotions to "stack" — meaning that the same item could trigger multiple promotions. For example, with promotions:

  • Buy a keyring and get Coke for free
  • Buy a keyring and get a pen for free

"stacking" would mean that customer can buy just one keyring and get both free gifts.

To achieve that, both promotions have to be marked as "stackable". This helps to avoid unexpected "stacking" between unrelated promotions.

Stacking still does NOT allow one item to receive multiple item-level discounts. An item discounted by a promotion is still marked as 'blocked' and will stay that way. Neither can a discounted item act as a trigger for next promotions.

9. Promotion Order

 Promotions are applied:

  • By tier: first all untiered promotions, then all the tiers in user-defined order.
  • Within each tier, by type. See the order of types below.
  • Within each type, from oldest to newest.

The types and their order is as follows. In the internal implementation, we have grouped related promotions into “algorithms”:

#NameAlgorithm
1Bundle PriceBundle Price
2Special Price With Bulk PurchaseSpecial Price With Bulk Purchase
3Discount With Bulk PurchaseDiscount With Bulk Purchase
4Buy One Get OneDiscount to Specific Items
5Buy And Get $ Off Your PurchaseDiscount to the Whole Purchase
6Buy And Get % Off EverythingDiscount to the Whole Purchase
7Spend And GetDiscount to Specific Items
8Spend And Get $ Off Your PurchaseDiscount to the Whole Purchase
9Spend And Get % Off EverythingDiscount to the Whole Purchase
10Pay With Loyalty PointsPay With Loyalty Points
11Get Discount For Loyalty PointsDiscount to Specific Items

 

10. Algorithm

The shopping cart is split so that each piece would be a separate line in an array. Eg. if customer buys 10 items, then the "array of products" will contain 10 separate lines. This is necessary to later mark which items have already been "blocked", which items are free, which get a discount.

If customer buys 2.5 pcs, then system will split it into three (1 piece, 1 piece, 0.5 pieces), but fractional pieces are "blocked" from the start and cannot receive item-level discounts.

When the procedure is completed, the lines are aggregated again and the discount is averaged over all pieces that were formerly together on one invoice line.

Invoice total is recalculated after applying each promotion. This way, promotions that depend on it ("Spend at least $x") will apply correctly. If you have two promotions "Spend $5 and get discount", and the initial total is exactly $5, only the first promotion will apply, and should apply. After the first promotion, invoice total is already less than $5!

10.1. Discount to Specific Items

This algorithm is used for types 4, 7, 11 in the list above:

  • Buy One Get One
  • Spend And Get
  • Get Discount For Loyalty Points

The promotion itself will be applied as many times as possible, but at most 1000 times (to avoid a possible infinite loop with a very large amount). Except when the condition is total invoice value (spend at least $X) — in that case it is applied only once.

When the promotion requires reward points, it is applied as user has specified: as many times as possible, or only once.

The promotion can give discount to 1, n, or unlimited items. 

Naturally, when the promotion requires reward points, users should be instructed not to use the "unlimited discounted items" option.

First ERPLY starts looking for the promotion conditions. Formerly this step was at the end, but otherwise we would not have been able to support giving discount to multiple items. 

If the condition is "Spend at least $X", then the total value of invoice BEFORE the discount must not be lower than that. (This is not quite correct, see TODO at the bottom)

If the condition is "Buy x products from group / category / list", then ERPLY walks through all the items, and looks up items that can trigger the promotion. If user has specified that the cheapest item must receive discount, then the items are checked in descending price order (most expensive ones first); otherwise in normal basket order. The x items can be different items, not necessarily all the same product. If there is an additional condition that only items from a certain price range qualify, then the item's original price is checked — does it belong into the specified range? The price range is inclusive: price $10 satisfies both "up to $10" and "at least $10".

ERPLY checks original price — not the current price, which might already be discounted by other promotions or manual discount. This has not been specifically requested, but the promotion "get specified product for a fixed price", see below, also uses original price for checking against the price range. Also, in POS the user typically picks items, the promotion kicks in, and after that user applies a manual discount. Maybe it is logical that applying the manual discount does not cause any items to go outside the price range, which would disable the promotion?

If the whole set of necessary triggers is found, these items are blocked. A blocked item cannot be the condition for any subsequent promotions, or receive any promotions. 

Then ERPLY looks up an item that can receive the discount. It can be an item from a list of products, or from a product group, or from a category. If user has specified that the cheapest item must receive discount, then the items are checked in ascending price order; otherwise in normal basket order. If the promotion can give discount to multiple items, the lookup process is repeated (n times or unlimited times, until no more are found). Finally, discounts are applied and the discounted items blocked. If ERPLY cannot find even one award, the prerequisites are unblocked again and the promotion skipped.

TODO: If the condition is "Spend at least $X", then we should check invoice total AFTER the discount, not before. Or another option, exclude awarded items from the total entirely. Eg, if there is a promotion "Spend $5 and get a free drink" then the promotion should not apply if you buy the drink only! To combine this correctly with "n items can get discount" feature, ERPLY should keep looking for awardable items as long as possible, recalculate the total after each find, and stop (and discard the last find) as soon as the basket total no longer satisfies promotion condition.

10.2. Bundle Price

This algorithm is used for type 1 in the priority list above.

In this promotion, items that trigger the promotion are also the ones that receive the discount. As with other promotions, items that have already been blocked are not considered, and the items that receive the discount will be blocked from subsequent promotions. Note that since promotions override price lists, this promotion will overwrite price list price even if list price is lower than promotion price.

This promotion applies as many times as possible. If the promotion is "Buy two for $5" and customer buys 6, then all the items will get the special price — ie. cart total will be $15.

First ERPLY starts looking for the promotion conditions. The condition can be N items from a product category, or product group, or a list of products. If promotion conditions require more than 1 item (eg. buy three for $10), then mix-and-match is supported: the three items do not necessarily have to be the same product. The promotion will apply a whole number of times. Eg. with "buy three for $10" and 5 items bought, 3 of them will get the special price $3.33 each and the remaining two will be at regular price.

If API client has specified a manual discount for this invoice row, the manual discount will be re-applied after the new promotional price has been set for the item. Generally, manual discounts are always applied first and all promotional discounts after that, with this promotion and the "Special Unit Price" promotion being the only exceptions (promotions which set a new fixed price, instead of applying a discount).

If there is an additional condition that only items in a certain price range qualify, then item's original price is checked — does it belong into the specified range? The price range is inclusive: price $10 satisfies both "up to $10" and "at least $10". (Item's original price is the price before manual discount. Since in this promotion manual discount applies AFTER promotion, using the original price makes a bit more sense.)

This promotion can be triggered with a coupon, but in the current implementation, scanning a single coupon will still run the promotion as many times as possible. This might need to be changed in the future.

10.3. Discount to the Whole Purchase

This algorithm is used for types 5, 6, 8, 9 in the list above:

  • Buy And Get $ Off Your Purchase
  • Buy And Get % Off Everything
  • Spend And Get $ Off Your Purchase
  • Spend And Get % Off Everything

When the promotion is "$ off" and activated with a coupon with a serial number, it will be applied as many times as there are coupons, or until invoice total is 0.

In all other cases, the promotion is applied once. Eg. a promotion "Buy a cake and get $5 off the purchase" only gives $5 discount, regardless of how many cakes you purchase.

First ERPLY starts looking for the promotion conditions

If the condition is "Spend at least $X", then the total value of invoice BEFORE the discount must not be lower than that.

If the condition is "Buy x products from group / category / list", then ERPLY walks through all the items, and looks up items that can trigger the promotion. The x items can be different items, not necessarily all the same product. If there is an additional condition that only items from a certain price range qualify, then the item's original price is checked — does it belong into the specified range? The price range is inclusive: price $10 satisfies both "up to $10" and "at least $10".

If the whole set of necessary triggers is found, these items are blocked. A blocked item cannot be the condition for any subsequent promotions, or receive any promotions. 

There may be a list of products that are excluded from receiving the discount. Or there can be a whitelist of items, and only these whitelisted items can receive discount. For "% off" promotions, this means that some lines on the invoice will get the discount percentage — some won't. For "$ off" promotions, this means that the dollar discount will be spread across just the discountable items, so that all in all, the customer will get the same $ reduction from their total.

Gift cards, products with the flag "Promotion discounts do not apply to this product" and products that belong to a product group with the same flag (or to the group's sub-groups), are excluded from discounting, too

If there is not even a single qualifying item in the shopping cart, the promotion will not be applied.

If there is a discount percentage, the discount will be applied to all qualifying rows.

If there is a $ discount, the discount total will be proportionately divided between the items. If this creates a rounding error (eg. line discounts add together to $4.99, but $5 needs to be taken off the sale), the algorithm goes back to the last discounted item and adjusts its price accordingly.

This promotion does not block the discounted items from receiving subsequent promotions.

10.4. Pay With Loyalty Points

This algorithm is used for type 10 in the priority list above.

The promotion does not apply to POS default customers, or to customers for whom reward points are disabled. (The second part is actually incorrect; the check box on customer card actually says, "This customer does not earn new reward points". The customer should still be allowed to spend their existing points.)

The promotion will apply as configured: only once, or as many times as possible — but no more than the customer's current point balance allows, and only while the discount is not greater than invoice total.

Or, if the promotion is restricted to providing at most x% of discount, then the promotion will only be applied as long as the discount does not exceed a specified fraction of the invoice total.

The discount will be proportionally divided between shopping cart items (same percentage from each item). This promotion will not block the items from receiving subsequent promotions.

Note that currently, this promotion:

  • does not respect the list of items excluded from discount, nor the whitelist of items that are allowed to receive a discount (as described in previous section).
  • does not specially adjust the last item in the shopping cart, to make sure that the total discount from the whole sale is exactly as specified (as described in previous section).

10.5. Discount With Bulk Purchase

This promotion will apply only once, because it will give discount to as many items as it can. If the promotion is "Buy 3 pcs or more and get $1 off of each one", and customer buys 7, all 7 items will get the discount.

First ERPLY starts looking for the promotion conditions. The condition can be N items from a product category, or product group, or a list of products. If promotion conditions require more than 1 item (eg. buy three for $10), then mix-and-match is supported: the three items do not necessarily have to be the same product.

If there is an additional condition that only items in a certain price range qualify, then item's original price is checked — does it belong into the specified range? 

When ERPLY has found enough items that satisfy the promotion's conditions, it continues looking by the same criteria, until it cannot find any more matching products. All found products (both the requirements and the products found after that) will get the discount, and will be blocked from subsequent promotions.

This promotion can be triggered with a coupon, but scanning a single coupon will still apply discount to as many items as possible.

10.6. Special Price With Bulk Purchase

 The promotion requires you to specify the final discounted unit price. It is quite closely related to two already existing promotion types:

  • "Buy X pcs. of product Y for total price Z";
  • "Buy X pcs. of product, and those items, and all similar subsequent items, will get a $ or % discount".

The promotion works like this: If customer has purchased N items from a product category, or product group, or a list of products, then the price for these items, and all the following similar ones — up to the limit of maxItemsWithSpecialUnitPrice, if specified — will be overwritten with the specified new unit price.

This is an "item-level" promotion, meaning that the items receiving the new price are blocked from subsequent discounts, or from triggering subsequent promotions.

If user has specified a minimum or maximum price for the items that customer must buy (there are no such fields in Classic back office, but they do exist in Berlin and API), then both the items that customer must buy, and the items the customer gets with a special price, must be in the specified price range.

If API client has specified a manual discount for this invoice row, the manual discount will be re-applied after the new promotional price has been set for the item.

The promotion supports the check box "Allow the same items to trigger other promotions after this promotion has been applied". The items that triggered the promotion and items that received the discount alongside with those must be treated the same way; they all can trigger other stackable promotions if needed.

 If the promotion is an automatic promotion, it only applies once.

If the promotion is a coupon or manual promotion, it should run only as many times as it has been invoked (or as many coupons have been scanned).

11. How Many Times Promotions Apply. Redemption Limit.

If the following promotions are automatic promotions, then they run only once. And if they are coupons or manual promotions, then they run as many times as they have been invoked:

  • (2) Special Price With Bulk Purchase
  • (3) Discount With Bulk Purchase
  • (5) Buy And Get $ Off Your Purchase
  • (7) Spend And Get
  • (8) Spend And Get $ Off Your Purchase

If the following promotions are automatic promotions, then they run as many times as possible. And if they are coupons or manual promotions, then they run as many times as they have been invoked:

  • (1) Bundle Price
  • (4) Buy One Get One

However, if the promotion is marked as a "one-time promotion" or "one-time birthday promotion", it will still apply only once, even if you attempt to invoke it multiple times.

All these promotion support setting a Redemption Limit (with the exception of "Discount With Bulk Purchase" — TODO!) "Redemption Limit" means that the promotion as a whole cannot apply to one sale more than the specified number of times.

A "Redemption Limit" of 3 means that the algorithm will make at most 3 passes across the shopping cart (checking the preconditions and finding items to discount). "Redemption Limit" is not confused with "the number of discounted items" (which can also be specified in BOGO and Special Price With Bulk Purchase) promotions.

The following promotions do not allow multiple invocation, and do not therefore support Redemption Limit, either.

The reasons are explained below.

1. For the following promotions, invoking them multiple times is not currently possible. It has not been requested yet and it is unclear how users may want it to work — either with cumulating percentages (where 10% subtracted twice gives -19%), or added percentages (10% twice resulting in 20%)

  • (6) Buy And Get % Off Everything
  • (9) Spend And Get % Off Everything

2. Reward point-related promotions have their own controls for invoking them either once or multiple times, and should be used only either manually or automatically — but probably not with coupons.

  • (10) Pay With Loyalty Points
  • (11) Get Discount For Loyalty Points

12. Promotion Statistics

In calculateShoppingCart response, there is information about which rows received a promotional discount, and how much discount was generated from each promotion.

If this shopping cart is turned into an actual sale, then it is the API client's responsibility to send the same information back to API saveSalesDocument, to provide information for the Promotion Report (Reports → Sales Promotions).

This data set is structured in a slightly unusual way, but this is for making usage easier; you can just gather all the fields prefixed with "promotionRule" and pass them on as input parameters to saveSalesDocument without having to re-convert any arrays.
The following fields describe the first promotion on first invoice line:

  • promotionRule1amount1
  • promotionRule1finalPrice1
  • promotionRule1totalDiscount1
  • promotionRule1campaignType1
  • promotionRule1campaignDiscountValue1
  • promotionRule1campaignDiscountPercentage1
  • promotionRule1campaignID1

Then, the second promotion on first invoice line:

  • promotionRule1amount2
  • promotionRule1finalPrice2
  • promotionRule1totalDiscount2
  • promotionRule1campaignType2
  • promotionRule1campaignDiscountValue2
  • promotionRule1campaignDiscountPercentage2
  • promotionRule1campaignID2

If the second invoice line also had promotions, the list of fields continues:

  • promotionRule2amount1
  • ...

Here is a description of the fields (also found in API documentation):

Field nameTypeDescription
promotionRule#campaignID#IntegerApplied promotion ID
promotionRule#amount#IntegerWhat quantity the promotion applied to, on this particular invoice line. If customer bought 2 or more of this item, but only one was with promotional discount (eg. a Buy One, Get One promotion), then this value is set to 1, for example.
promotionRule#finalPrice#DecimalFinal line total (price * quantity) immediately AFTER applying the promotion
promotionRule#totalDiscount#DecimalTotal $ discount that promotion gave to this invoice line.
promotionRule#campaignType#String "ITEMS" or "INVOICE""ITEMS" for line or item discounts; "INVOICE" for any discounts that applied to the whole document. (Since there is no "invoice discount" concept in ERPLY, invoice discounts need to be divided proportionally between invoice lines.)
promotionRule#campaignDiscountValue#DecimalDollar discount that was specified in promotion parameters — IF this was a dollar discount promotion (eg. "Get $20 off of all shoes", or "3 beers for $10". Field value should be 20 or 10, for these two examples.)
promotionRule#campaignDiscountPercentage#DecimalPercentage discount as it was defined in promotion description — IF this was a percentage discount promotion (eg "10% off").

This dataset does not include lines where a promotion was applied, but it did not give any discount (for example, line price was already 0).

Since API version 1.9.0, invoice-level promotions that applied multiple times (whether by scanning multiple coupons, or by manually invoking the promotion multiple times) will be reported as one statistics record.

Before that, each coupon generated a separate statistics record.