Custom Promotion Scripts

What’s on this page?

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.

graph LR A[Custom Promotion Script] --> C; B[Promotion Config] --> C; C[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-level Cart object
  • this.parameters is a structure containing the discount-specific values for all the custom parameters defined for this script
  • this.discount is a Discount 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 more Baskets
  • A Basket contains a set of one or more CartItems 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
  • Discounts can be applied to CartItems (i.e. an item-level discount), and to Baskets (i.e. an order-level discount)

Classes

Cart
Read-only propertiestulip_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
MethodsgetAttribute(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 propertiestulip_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
MethodshasDiscountWithId(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 CartItems 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 propertiestulip_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
MethodsgetAttribute(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 propertiestulip_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 propertiestulip_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
MethodsgetAttribute(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 propertiestulip_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
MethodsgetAttribute(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 propertiestulip_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.
MethodsgetAttribute(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 propertiestulip_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.
MethodsgetAttribute(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 propertiestulip_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 propertiestulip_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 propertiestulip_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 by eligible_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 discounts
  • get_y determines how many discounts will be able to be applied for each set of discounts
  • maximum_number_of_discounted_items determines the maximum number of items this discount can be applied to
  • exclusive 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:

  1. $110
  2. $109
  3. $108
  4. $107
  5. $106
  6. $105
  7. $104
  8. $103
  9. $102
  10. $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

  1. Navigate to the admin console.
  2. Go to Tulip Admin > Promotion Scripts.
  3. Select Add Promotion Script.
  4. On the page that appears, fill in the fields of your new promotion script such as the Name and Description.
  5. To add variables for your custom script, on the lower left side of the page, select Add Variable.
  6. Fill in the required fields for your new variable such as the Name and variable Type, and then select Save.
  7. On the right pane, add your JavaScript custom script, and then select Save.