Custom Promotion Scripts
What’s on this page?
- Key Principals
- Programing Language
- Interface Overview
- Classes
- Examples
- How To: Create a custom promotion script
Custom promotion scripts allow retailers to write arbitrary logic to implement custom promotions that are applied during transactions.
Retailers can use custom promotion scripts combined with a set of parameters, to define complex promotions that may not be available out of the box. Scripts are written in JavaScript, so almost any custom logic can be implemented. This allows great flexibility in the types of promotions a retailer can make available.
Key Principals
Custom promotion scripts, along with a set of configuration parameters combine to form a specific promotion.
Programing Language
All custom promotion scripts are written in Javascript, and support standard ES6 compatible Javascript. Loading libraries is not allowed.
Interface Overview
All promotions, whether defined as item-level discounts or order level discounts, are applied identically. The promotion script receives the entire cart as an object, along with all items, and is able to apply changes to the order, through provided methods, before it exits.
Modifications to the cart are applied to a copy of the cart, and then merged by Tulip once the promotion scripts are completed. The promotion scripts are run whenever the cart or any of its items are modified. Before the strategies run, all pre-existing discounts which were added by promotion scripts are removed; scripts are expected to re-add a discount if the items still qualify.
Each promotion script is a class that extends PromotionScript
and defines a process()
method as its main entry point:
class MyScript extends PromotionScript {
process() {
// Your code
}
}
(the MyScript
name can be anything you want).
The PromotionScript
superclass defines three properties for use by scripts:
this.cart
is the top-levelCart
objectthis.parameters
is a structure containing the discount-specific values for all the custom parameters defined for this scriptthis.discount
is aDiscount
object representing the discount being evaluated.
Tulip structures its cart data model as follows:
- The
Cart
object is the main entry point - A
Cart
contains one or moreBasket
s - A
Basket
contains a set of one or moreCartItem
s that are to be fulfilled from the same location (i.e. in store, online order, shipped from another store) - Each
Basket
is checked out separately to become an order - A
CartItem
represents a specific, individual SKU being purchased Discount
s can be applied toCartItem
s (i.e. an item-level discount), and toBasket
s (i.e. an order-level discount)
Classes
Cart | |
---|---|
Read-only properties | • tulip_id : The Tulip ID for the cart• store_id : The Tulip ID for the store where the transaction is happening (i.e. the store the associate is signed into)• store : A Store object representing the store where the transaction is happening• customer_id : The Tulip ID for the customer associated with the cart• customer : A Customer object for the customer associated with the cart. Returns null if no customer is set.• baskets : An array of Basket objects• is_blind_return : A boolean flag denoting if this is a blind return cart |
Methods | • getAttribute(name) : Returns the attribute value for the given custom attribute name set on the cart. Returns null if no attribute with that name has been set.• setAttribute(name, value) : Sets the custom attribute for the cart with the given name to the given value.• removeAttribute(name) : Unsets any existing attribute for the cart with the given name. |
Basket | |
---|---|
Read-only properties | • tulip_id : The Tulip ID for the basket• ref_num : The External ID for the basket• basket_type_id • items : An array of CartItem ’s contained in this Basket |
Methods | • hasDiscountWithId(tulip_id) : Returns true if a discount with the given Tulip ID has already been added to this basket, false otherwise.• applyDiscount(discount, amount) : Applies the given Discount object to the basket. The amount parameter is optional, and can only be used for “custom” discounts that allow overriding the amount on a case-by-case basis.• addItem(product_ref_num, variant_ref_num) : Adds a new CartItem to the basket, for the given product and variant. Returns the newly-added CartItem object.• removeItems(items_to_remove) : Removes the CartItem ’s in the given array from the basket, if they are present there.• findItemsWithAttributeValue(attribute_name, attribute_value) : Returns an array containing any CartItem s in this basket which have the given (attribute_name, attribute_value) pair.• containsItemWithAttributeValue(attribute_name, attribute_value) : Returns true if the basket contains any item with the given (attribute_name, attribute_value) pair, false otherwise. |
CartItem | |
---|---|
Read-only properties | • tulip_id : The Tulip ID for the cart item. Note that this may be null , if the item has not been inserted in the database yet (e.g. if it was just added by a promotion script).• ref_num : The External ID for the cart item• product_id : The Tulip ID for the product referenced by this cart item• product : The Product object for the product referenced by this cart item• variant_id : The Tulip ID for the variant referenced by this cart item• variant : The Variant object for the variant referenced by this cart item• price : The non-discounted line item price for this cart item• applied_discounts : Array of Discount objects for the discounts applied to this cart item• is_blind_return : A boolean flag denoting if this is a blind return cart item• price_override : The overridden price applied to an item. If no price override has been applied, this field will be null |
Methods | • getAttribute(name) : Returns the attribute value for the given custom attribute name set on the item. Returns null if no attribute with that name has been set.• setAttribute(name, value) : Sets the custom attribute for the item with the given name to the given value.• removeAttribute(name) : Unsets any existing attribute for the item with the given name.• hasDiscountWithId(tulip_id) : Returns true if a discount with the given Tulip ID has already been added to this item, false otherwise.• applyDiscount(discount, amount) : Applies the given Discount object to the item. The amount parameter is optional, and can only be used for “custom” discounts that allow overriding the amount on a case-by-case basis.• markAsRelatedToDiscount(discount) : Marks this cart item as “related” to the given Discount object, without applying the discount to this item. (e.g. call this on the “buy” item in a “buy one get one” promotion to prevent subsequent promotions from applying to that item.)• hasOrRelatesToDiscounts() : Returns true if this item has had a discount already applied to it, or has been marked as related to a discount applied to another item. Returns false otherwise. |
Discount | |
---|---|
Read-only properties | • tulip_id : The Tulip ID for the discount• ref_num : The External ID for the discount. May be null .• applied_discount_id : The Tulip ID for this application of the discount. May be null .• type : Describes the nature of the amount value with regard to how the discount is calculated. Valid values are percentage or currency .• amount : The amount of the discount.• name : The name of the discount.• source : The origin/nature of the discount. Possible values are manual , promotion , script , and extensibility-hook . (Discounts applied through the promotion scripts all have source set to script .)• priority : An integer indicating the relative order with which script discounts are applied. Lower values are ordered before higher values. |
Product | |
---|---|
Read-only properties | • tulip_id : The Tulip ID for the product• ref_num : The External ID for the product• name : The name of the product• categories : Array of Tulip IDs for the categories the product belongs to• categories_ref_nums : Array of External IDs for the categories the product belongs to |
Methods | • getAttribute(name) : Returns the attribute value for the given custom attribute name set on the product. Returns null if no attribute with that name has been set. |
Variant | |
---|---|
Read-only properties | • tulip_id : The Tulip ID for the variant• ref_num : The External ID for the variant• name : The name of the variant• option_value_ref_nums : An array containing the External IDs for the option values associated with the variant |
Methods | • getAttribute(name) : Returns the attribute value for the given custom attribute name set on the variant. Returns null if no attribute with that name has been set. |
Store | |
---|---|
Read-only properties | • tulip_id : The Tulip ID for the store• ref_num : The External ID for the store• name : The name of the store• address : The street address for the store• city : The city part of the store’s address• postal_code : The store’s postal or zip code• zone_iso_code : The ISO code for the province or state of the store. Specifically, the regional component (i.e. the part after the hyphen) of the ISO 3166-2 code for the region.• country_iso_code : The ISO 3166-1 alpha-2 code for the country of the store.• phone : The store’s phone number.• latitude : The latitude component of the store’s location.• longitude : The longitude component of the store’s location. |
Methods | • getAttribute(name) : Returns the attribute value for the given custom attribute name set on the store. Returns null if no attribute with that name has been set. |
Customer | |
---|---|
Read-only properties | • tulip_id : The Tulip ID for the customer.• ref_num : The External ID for the customer.• addresses : Array of Address objects associated with this customer.• important_dates : Array of ImportantDate objects associated with this customer.• tier_values : Array of CustomerTierValue objects associated with this customer. |
Methods | • getAttribute(name) : Returns the attribute value for the given custom attribute name set on the customer. Returns null if no attribute with that name has been set. |
Address | |
---|---|
Read-only properties | • tulip_id : The Tulip ID for the address.• ref_num : The External ID for the address.• postal_code : The postal or zip code.• city : The city part of the address.• zone_iso_code : The ISO code for the province or state. Specifically, the regional component (i.e. the part after the hyphen) of the ISO 3166-2 code for the region.• country_iso_code : The ISO 3166-1 alpha-2 code for the country.• is_primary : true if the address has been flagged as the customer’s primary address, false otherwise. |
ImportantDate | |
---|---|
Read-only properties | • tulip_id : The Tulip ID for the important date entry.• type : The Tulip ID for the type of important date being referenced.• date : The date of the important event, expressed as a ISO8601 string (e.g. 2021-12-25 ). Note that the year may be set to 0001 , indicating that the customer did not specify a year.• title : A string indicating the type of date (e.g. Birthday , Anniversary , or possibly some customer-specified value). |
CustomerTierValue | |
---|---|
Read-only properties | • tulip_id : The Tulip ID for the tier value.• tier_id : The Tulip ID for the tier that this value belongs to.• tier_ref_num : The External ID for the tier that this value belongs to.• name : The name of the tier value.• colour : The colour associated with this tier value. |
Examples
Example 1: Universal automatic item-level discount
This basic script automatically adds a discount to _all_ items
in all baskets of the cart.
class UniversalBasicItemDiscount extends PromotionScript {
process() {
for (const basket of this.cart.baskets) {
for (const item of basket.items) {
item.applyDiscount(this.discount);
}
}
}
}
Example 2: Discount for items in a specific category
This script automatically adds a discount to all items in a given category. The category is specified by its ref num (aka External ID) in a eligible_category_ref_num
custom parameter for this promotion script.
class CategoryItemDiscount extends PromotionScript {
process() {
for (const basket of this.cart.baskets) {
for (const item of basket.items) {
if (item.product.categories_ref_nums.includes(this.parameters.eligible_category_ref_num)) {
item.applyDiscount(this.discount);
}
}
}
}
}
Example 3: Buy _x_ Get _y_ Promotion Script
This script handles a variety of “Buy x, get y with some discount” promotions to be applied to any products within a given category.
Script Parameters:
[
{"name":"eligible_category_ref_num","type":"text"},
{"name":"buy_x","type":"integer"},
{"name":"get_y","type":"integer"},
{"name":"exclusive","type":"integer"},
{"name":"maximum_number_of_discounted_items","type":"integer"}
]
Example values
{"eligible_category_ref_num":"category-mens-t-shirts","buy_x":1,"get_y":1,"exclusive":0,"maximum_number_of_discounted_items":-1}
Where:
eligible_category_ref_num
determines base eligibility (i.e. items whose products are in the category specified byeligible_category_ref_num
can participate in the discount)buy_x
determines how many non-discounted items must be present in the basket to open up a set of discountsget_y
determines how many discounts will be able to be applied for each set of discountsmaximum_number_of_discounted_items
determines the maximum number of items this discount can be applied toexclusive
is a flag to control whether already-discounted items are eligible to have the “get y” discount stacked on their existing discounts.
It applies the logic that the items receiving the discount (i.e. the “get y” ) must be equal or lesser in price to the corresponding “buy x” items (which must also be non-discounted).
e.g. Suppose you have a 20% off promotion using this script with parameters buy_x: 3, get_y: 2, maximum_number_of_discounted_items: 3
, and a cart containing 10 eligible items with no prior discounts and the following prices:
- $110
- $109
- $108
- $107
- $106
- $105
- $104
- $103
- $102
- $101
What would happen is:
- Items 1, 2, and 3 would collectively trigger the first “buy 3” set
- Items 4 and 5 would each receive a 20% discount as members of the first “get 2” set
- Items 6, 7, and 8 would trigger the second “buy 3” set
- Item 9 would receive a 20% discount as a member of the second “get 2” set
- Item 10 would not receive a discount because
maximum_number_of_discounted_items: 3
, and items 4, 5, and 9 have already used up that maximum.
A classic “Buy 1 Get 1 Free” promotion can be configured using this script by creating a promotion with a 100% discount and buy_x: 1, get_y: 1
as parameter values.
class BuyXGetYInCategory extends PromotionScript {
process() {
for (const basket of this.cart.baskets) {
let eligible_items = [];
for (const item of basket.items) {
if (item.product.categories_ref_nums.includes(this.parameters.eligible_category_ref_num)) {
if (this.parameters.exclusive != 0 && item.hasOrRelatesToDiscounts()) {
console.debug("Skipping item " + item.product.ref_num + " because it already has " + item.applied_discounts.length + " discounts");
continue;
}
eligible_items.push(item);
}
}
// Sort by descending price
eligible_items.sort((a, b) => b.price - a.price);
let unused_donors = [];
let currently_using_donors = [];
let unused_donor_count = 0;
let unused_recipient_count = 0;
let most_recent_donor_price = 0;
let number_of_discounted_items = 0;
let maximum_number_of_discounted_items = this.parameters.maximum_number_of_discounted_items;
if (maximum_number_of_discounted_items < 0) {
maximum_number_of_discounted_items = Infinity;
}
let eligible_recipients_past = [];
// Items are divided into 'donors' (which sit on the 'buy x' side),
// and 'recipients' (which receive the 'get y' discount).
// Within each matched subset of donors and recipients:
// 1. the donors must all be non-discounted
// 2. the price of each donor must be greater than
// the prices of any recipient.
// 3. recipients may optionally already have discounts if
// allowed by 'exclusive' flag.
//
// We seek to ensure that the highest discount is granted,
// subject to the above.
//
// i.e. if there are eligible items worth [$100, $90, $50, $40],
// and buy_x=1, get_y=1, then:
// - the $100 item should enable the discount to be applied to
// the $90 item
// - then the $50 item should enable discounting the $40 item
//
// However, suppose exclusive=false, and the $100 item has
// another discount. In this case, it cannot be a donor,
// but it can still be a recipient.
//
// TODO: look at post-discounted amount instead of price?
//
for (const item of eligible_items) {
if (unused_donor_count >= this.parameters.buy_x) {
unused_donor_count -= this.parameters.buy_x;
currently_using_donors = unused_donors;
unused_donors = [];
unused_recipient_count += this.parameters.get_y;
}
while (unused_recipient_count > 0 && eligible_recipients_past.length > 0 && number_of_discounted_items < maximum_number_of_discounted_items) {
recipient = eligible_recipients_past.shift();
if (recipient.price <= most_recent_donor_price) {
console.debug("Applying discount to previously skipped item " + recipient.product.ref_num);
recipient.applyDiscount(this.discount);
number_of_discounted_items += 1;
unused_recipient_count -= 1;
// Mark the donor items as being used
for (const donor_item of currently_using_donors) {
donor_item.markAsRelatedToDiscount(this.discount);
}
currently_using_donors = [];
}
}
if (unused_recipient_count > 0 && number_of_discounted_items < maximum_number_of_discounted_items) {
item.applyDiscount(this.discount);
number_of_discounted_items += 1;
unused_recipient_count -= 1;
// Mark the donor items as being used
for (const donor_item of currently_using_donors) {
donor_item.markAsRelatedToDiscount(this.discount);
}
currently_using_donors = [];
} else {
if (this.parameters.exclusive == 0 && item.hasOrRelatesToDiscounts()) {
// Item cannot be a donor, but it can be a recipient
console.debug("Item " + item.product.ref_num + " cannot be donor but can be recipient, adding to eligible past array");
eligible_recipients_past.push(item);
} else {
unused_donor_count += 1;
unused_donors.push(item);
most_recent_donor_price = item.price;
}
}
}
// If the last item was a donor, need to check if it can apply to earlier items
if (unused_donor_count >= this.parameters.buy_x) {
unused_donor_count -= this.parameters.buy_x;
currently_using_donors = unused_donors;
unused_donors = [];
unused_recipient_count += this.parameters.get_y;
}
while (unused_recipient_count > 0 && eligible_recipients_past.length > 0 && number_of_discounted_items < maximum_number_of_discounted_items) {
recipient = eligible_recipients_past.shift();
if (recipient.price <= most_recent_donor_price) {
console.debug("Applying discount to previously skipped item " + recipient.product.ref_num);
recipient.applyDiscount(this.discount);
number_of_discounted_items += 1;
unused_recipient_count -= 1;
// Mark the donor items as being used
for (const donor_item of currently_using_donors) {
donor_item.markAsRelatedToDiscount(this.discount);
}
currently_using_donors = [];
}
}
}
}
}
How To: Create a custom promotion script
- Navigate to the admin console.
- Go to Tulip Admin > Promotion Scripts.
- Select Add Promotion Script.
- On the page that appears, fill in the fields of your new promotion script such as the Name and Description.
- To add variables for your custom script, on the lower left side of the page, select Add Variable.
- Fill in the required fields for your new variable such as the Name and variable Type, and then select Save.
- On the right pane, add your JavaScript custom script, and then select Save.