Remove discount code for a specific line item

NicoSpoke
Shopify Partner
16 0 8

In our store we have some custom discount logic that does the following:

 

  1. Applies a discount code (changes line item price) "Buy X for Z" based on this Shopify Scripts example: https://help.shopify.com/en/manual/checkout-settings/script-editor/examples/line-item-scripts#buy-a-...
    • In our case instead of using a specific product tag, we use the product_type instead
  2. We don't want %percentage or value discount codes to be applied on those line items that have been discounted automatically by the previous script mentioned above. In order to do that we run this Shopify Script example: https://help.shopify.com/en/manual/checkout-settings/script-editor/examples/line-item-scripts#disabl...
    • We heavily customised this script so that it can work for us
    • We skip running this script if not line items have been automatically discounted by the script "Buy X for Z" or if the line item is a gift card
    • We store the discount code values in variables, we then change the price per line item depending on the value of the discount code
    • After applying the new line item price we then remove the discount code from the cart (to avoid have 2 discounts per line item)

 

Here is our scripts:

# ================================ Customizable Settings ================================
# ================================================================
# Buy X of Product Y for $Z
#
# Buy a certain number of matching items for a specific price.
# For example:
#
#   "Buy 2 t-shirts for $20"
#
#   - 'product_selector_match_type' determines whether we look for
#     products that do or don't match the entered selectors. Can
#     be:
#       - ':include' to check if the product does match
#       - ':exclude' to make sure the product doesn't match
#   - 'product_selector_type' determines how eligible products
#     will be identified. Can be either:
#       - ':tag' to find products by tag
#       - ':type' to find products by type
#       - ':vendor' to find products by vendor
#       - ':product_id' to find products by ID
#       - ':variant_id' to find products by variant ID
#       - ':subscription' to find subscription products
#       - ':all' for all products
#   - 'product_selectors' is a list of identifiers (from above)
#     for qualifying products. Product/Variant ID lists should
#     only contain numbers (ie. no quotes). If ':all' is used,
#     this can also be 'nil'.
#   - 'quantity_to_buy' is the number of products needed to
#     qualify
#   - 'final_price` is the amount to charge for all products that
#     are part of the offer
#   - 'discount_message' is the message to show when a discount
#     is applied
# ================================================================

MULTIBUY_DISCOUNTS = [
  {
    product_selector_match_type: :include,
    product_selector_type: :type,
    product_selectors: ["T-Shirts"],
    product_price: 40,
    quantity_to_buy: 3,
    final_price: 105,
    discount_message: 'Buy 3 for $105',
  },
  {
    product_selector_match_type: :include,
    product_selector_type: :type,
    product_selectors: ["Pants"],
    product_price: 89,
    quantity_to_buy: 2,
    final_price: 160,
    discount_message: 'Buy 2 for $160',
  }
]

#### Multibuy Items storage ####
# Store multibuy product ids to run the DisableDiscountCodesCampaign
multibuy_items = []

# ================================ Script Code (do not edit) ================================
# ================================================================
# ProductSelector
#
# Finds matching products by the entered criteria.
# ================================================================
class ProductSelector
  def initialize(match_type, selector_type, selectors)
    @match_type = match_type
    @comparator = match_type == :include ? 'any?' : 'none?'
    @Selector_type = selector_type
    @selectors = selectors
  end

  def match?(line_item)
    if self.respond_to?(@selector_type)
      self.send(@selector_type, line_item)
    else
      raise RuntimeError.new('Invalid product selector type')
    end
  end

  def tag(line_item)
    product_tags = line_item.variant.product.tags.map { |tag| tag.downcase.strip }
    @selectors = @selectors.map { |selector| selector.downcase.strip }
    (@selectors & product_tags).send(@comparator)
  end

  def type(line_item)
    @selectors = @selectors.map { |selector| selector.downcase.strip }
    (@match_type == :include) == @selectors.include?(line_item.variant.product.product_type.downcase.strip)
  end

  def vendor(line_item)
    @selectors = @selectors.map { |selector| selector.downcase.strip }
    (@match_type == :include) == @selectors.include?(line_item.variant.product.vendor.downcase.strip)
  end

  def product_id(line_item)
    (@match_type == :include) == @selectors.include?(line_item.variant.product.id)
  end

  def variant_id(line_item)
    (@match_type == :include) == @selectors.include?(line_item.variant.id)
  end

  def subscription(line_item)
    !line_item.selling_plan_id.nil?
  end

  def all(line_item)
    true
  end
end

# ================================================================
# DollarDiscountApplicator
#
# Applies the entered discount to the supplied line item.
# ================================================================
class DollarDiscountApplicator
  def initialize(discount_message)
    @discount_message = discount_message
  end

  def apply(cart, line_item, discount_amount)
    new_line_price = line_item.line_price - discount_amount
    
    if (line_item.line_price > new_line_price)
      line_item.change_line_price(new_line_price, message: @discount_message)
    end
  end
end

# ================================================================
# BuyXOfYForZCampaign
#
# Buy a certain number of matching items for a specific price.
# ================================================================
class BuyXOfYForZCampaign
  def initialize(campaigns)
    @campaigns = campaigns
  end

  def run(cart, multibuy_items)
    @campaigns.each do |campaign|
      product_selector = ProductSelector.new(
        campaign[:product_selector_match_type],
        campaign[:product_selector_type],
        campaign[:product_selectors],
      )      
      product_price = campaign[:product_price]

      eligible_items = cart.line_items.select { |line_item| line_item.variant.price == Money.new(cents: 100) * product_price && product_selector.match?(line_item) }

      next if eligible_items.nil?
      
      final_price = campaign[:final_price]

      eligible_item_count = eligible_items.map(&:quantity).reduce(0, :+)
      
      quantity_to_buy = campaign[:quantity_to_buy]
      number_of_offers = (eligible_item_count / quantity_to_buy).floor

      next unless number_of_offers > 0
      
      number_of_discountable_items = number_of_offers * quantity_to_buy
      total_offer_price = Money.new(cents: 100) * (number_of_offers * final_price)
      discount_applicator = DollarDiscountApplicator.new(campaign[:discount_message])
      self.loop_items(cart, eligible_items, number_of_discountable_items, number_of_offers, total_offer_price, discount_applicator, final_price, quantity_to_buy, product_price, multibuy_items)
    end
  end
  
  def loop_items(cart, line_items, num_to_discount, number_of_offers, total_price, discount_applicator, final_price, quantity_to_buy, product_price, multibuy_items)
    current_price = Money.zero
    avg_price = total_price * (1 / num_to_discount)
    line_items = line_items.sort_by { |line_item| line_item.variant.price }
    
    line_items.each do |line_item|
      break if num_to_discount <= 0
      
      if line_item.quantity > num_to_discount
        split_line_item = line_item.split(take: num_to_discount)
        discount_amount = split_line_item.line_price - (total_price - current_price)
        multibuy_items.push(line_item.variant.product.id)
        discount_applicator.apply(cart, split_line_item, discount_amount)
        position = cart.line_items.find_index(line_item)
        cart.line_items.insert(position + 1, split_line_item)
        
        break
      elsif line_item.quantity == num_to_discount
        discount_amount = line_item.line_price - (total_price - current_price)
        multibuy_items.push(line_item.variant.product.id)
        discount_applicator.apply(cart, line_item, discount_amount)
        break
      else
        if line_item.variant.price <= avg_price
          current_price += line_item.line_price
        else
          discount_amount = (line_item.variant.price - avg_price) * line_item.quantity
          current_price += (line_item.line_price - discount_amount)
          multibuy_items.push(line_item.variant.product.id)
          discount_applicator.apply(cart, line_item, discount_amount)
        end

        num_to_discount -= line_item.quantity
      end
    end
  end
end

# ================================================================
# DisableDiscountCodesCampaign
#
# Disable discount code if Multibuy products are in the cart
# ================================================================
class DisableDiscountCodesCampaign
  def run(cart, multibuy_items)
    return if cart.discount_code.nil? or multibuy_items.length > 0

    # Runs through a loop of items in your cart
    cart.line_items.each do |line_item|
      product = line_item.variant.product

      # next if product.gift_card? or product.product_type == "Gift Card"
      
      # skip multibuy line item
      next if multibuy_items.include?(product.id)
      
      # store the discount code message before it's removed later
      discount_message = cart.discount_code.code

      # 1. check discount code type applied to checkout
      case cart.discount_code
        when CartDiscount::Percentage
          # 2. collects all the info from the discount
          discount_amount = cart.discount_code.percentage.floor().to_s.to_i
          value =  1 - (discount_amount * 0.01)
          new_line_price = line_item.line_price * value
          # store again the discount message as a string (it was breaking the logic below for some reason)
          d_message = "#{discount_message}"
          # 3. update the price of the line item with the discount code discounted price and message
          line_item.change_line_price(new_line_price, message: d_message)
          # 4. then remove the discount code from the basket
          cart.discount_code.reject({ message: 'Discount cannot be applied on multibuy offers' })
      end
    end
  end
end


CAMPAIGNS = [
  BuyXOfYForZCampaign.new(MULTIBUY_DISCOUNTS),
  DisableDiscountCodesCampaign.new()
]

CAMPAIGNS.each do |campaign|
  campaign.run(Input.cart, multibuy_items)
end

Output.cart = Input.cart

 

The code above works, but I feel like there must be a better way to achieve this. I've also noticed that debugging within the Shopify Scripts editor is not efficient and results in this editor are not the same as the actual store. Perhaps there's a more efficient way to write these scripts that I could benefit from?

 

Much appreciated.

 

 

Building digital interfaces for Shopify Stores
Reply 1 (1)

NicoSpoke
Shopify Partner
16 0 8

Update:

 

The code above worked well within the Scripts Editor debugging tool but was less efficient in a production environment. It just shows that you can't rely on Shopify's Script Editor, is doesn't match the live environment that customers experience. 

 

The way "discount.reject" works is not optimal. It seems Shopify clears the discount object inside the cart data once "discount.reject" is used. It would be much better if discount code data had a state machine. So instead of removing any discount data from the cart object, it would simply update the state (status) of the discount "ACTIVE" or "INACTIVE". That way, even if the discount is not applicable to the cart we can still use the discount values of the discount to update the prices of each line item if we desire to do so. 

Building digital interfaces for Shopify Stores