Bundle discount - a way to exclude subscription products from a discount?

Topic summary

A Shopify merchant is attempting to create a bundle discount that applies only to one-time purchases, excluding subscription products managed through Recharge.

Core Challenge:

  • Bundle discount currently applies to both one-time and subscription purchases
  • Subscription and one-time products share the same product ID, preventing ID-based filtering
  • Need a method to distinguish between purchase types within the discount script

Technical Context:

  • Using Shopify Script Editor with Ruby code
  • All products process through Shopify checkout
  • The existing script includes a Campaign class with qualifiers for bundle logic

Current Status:

  • The merchant has shared their existing Ruby discount script code
  • No solution has been identified yet
  • Seeking community input on filtering approaches that don’t rely on product IDs
Summarized with AI on November 18. AI used: claude-sonnet-4-5-20250929.

Hello, I’m trying to exclude subscription products (managed via Recharge) from a custom bundle discount, all sold via the Shopify checkout.

With the current discount script added to “script” editor". The bundle discount is applied if a product is added a subscription. But we only want this on “one-time” purchases.

Does anyone know a good way to filter out the subscription products? - The issue I have just now is that they have the same product ID, so my idea to exclude that is out the window.

Our code currently is:

# BUNDLE DEAL CODE
class Campaign
  def initialize(condition, *qualifiers)
    @condition = (condition.to_s + '?').to_sym
    @qualifiers = PostCartAmountQualifier ? [] : [] rescue qualifiers.compact
    _item_selector = qualifiers.last unless _item_selector
    qualifiers.compact.each do |qualifier|
      is_multi_select = qualifier.instance_variable_get(:@conditions).is_a?(Array)
      if is_multi_select
        qualifier.instance_variable_get(:@conditions).each do |nested_q|
          _amount_qualifier = nested_q if nested_q.is_a?(PostCartAmountQualifier)
          @qualifiers << qualifier
        end
      else
        _amount_qualifier = qualifier if qualifier.is_a?(PostCartAmountQualifier)
        @qualifiers << qualifier
      end
    end if @qualifiers.empty?
  end

  def qualifies?(cart)
    return true if @qualifiers.empty?
    @unmodified_line_items = cart.line_items.map do |item|
      new_item = item.dup
      new_item.instance_variables.each do |var|
        val = item.instance_variable_get(var)
        new_item.instance_variable_set(var, val.dup) if val.respond_to?(:dup)
      end
      new_item
    end if _amount_qualifier
    @qualifiers.send(@condition) do |qualifier|
      is_selector = false
      if qualifier.is_a?(Selector) || qualifier.instance_variable_get(:@conditions).any? { |q| q.is_a?(Selector) }
        is_selector = true
      end rescue nil
      if is_selector
        raise "Missing line item match type" if _match_type.nil?
        cart.line_items.send(@li_match_type) do |item|
          next false if item.nil?
          qualifier.match?(item)
        end
      else
        qualifier.match?(cart, _item_selector)
      end
    end
  end

  def run_with_hooks(cart)
    before_run(cart) if respond_to?(:before_run)
    run(cart)
    after_run(cart)
  end

  def after_run(cart)
    .apply_final_discount if  && .respond_to?(:apply_final_discount)
    revert_changes(cart) unless _amount_qualifier.nil? || _amount_qualifier.match?(cart)
  end

  def revert_changes(cart)
    cart.instance_variable_set(:@line_items, @unmodified_line_items)
  end
end

class BundleDiscount < Campaign
  def initialize(condition, customer_qualifier, cart_qualifier, discount, full_bundles_only, bundle_products)
    super(condition, customer_qualifier, cart_qualifier, nil)
    _products = bundle_products
     = discount
    _bundles_only = full_bundles_only
    @split_items = []
    _items = []
  end

  def check_bundles(cart)
      bundled_items = _products.map do |bitem|
        quantity_required = bitem[:quantity].to_i
        qualifiers = bitem[:qualifiers]
        type = bitem[:type].to_sym
        case type
          when :ptype
            items = cart.line_items.select { |item| qualifiers.include?(item.variant.product.product_type) && !item.discounted? }
          when :ptag
            items = cart.line_items.select { |item| (qualifiers & item.variant.product.tags).length > 0 && !item.discounted? }
          when :pid
            qualifiers.map!(&:to_i)
            items = cart.line_items.select { |item| qualifiers.include?(item.variant.product.id) && !item.discounted? }
          when :vid
            qualifiers.map!(&:to_i)
            items = cart.line_items.select { |item| qualifiers.include?(item.variant.id) && !item.discounted? }
          when :vsku
            items = cart.line_items.select { |item| (qualifiers & item.variant.skus).length > 0 && !item.discounted? }
        end

        total_quantity = items.reduce(0) { |total, item| total + item.quantity }
        {
          has_all: total_quantity >= quantity_required,
          total_quantity: total_quantity,
          quantity_required: quantity_required,
          total_possible: (total_quantity / quantity_required).to_i,
          items: items
        }
      end

      max_bundle_count = bundled_items.map{ |bundle| bundle[:total_possible] }.min if _bundles_only
      if bundled_items.all? { |item| item[:has_all] }
        if _bundles_only
          bundled_items.each do |bundle|
            bundle_quantity = bundle[:quantity_required] * max_bundle_count
            split_out_extra_quantity(cart, bundle[:items], bundle[:total_quantity], bundle_quantity)
          end
        else
          bundled_items.each do |bundle|
            bundle[:items].each do |item|
              _items << item
              cart.line_items.delete(item)
            end
          end
        end
        return true
      end
      false
  end

  def split_out_extra_quantity(cart, items, total_quantity, quantity_required)
    items_to_split = quantity_required
    items.each do |item|
      break if items_to_split == 0
      if item.quantity > items_to_split
        _items << item.split({take: items_to_split})
        @split_items << item
        items_to_split = 0
      else
        _items << item
        split_quantity = item.quantity
        items_to_split -= split_quantity
      end
      cart.line_items.delete(item)
    end
    cart.line_items.concat(@split_items)
    @split_items.clear
  end

  def run(cart)
    raise "Campaign requires a discount" unless 
    return unless qualifies?(cart)

    if check_bundles(cart)
      _items.each { |item| .apply(item) }
    end
    _items.reverse.each { |item| cart.line_items.prepend(item) }
  end
end

class PercentageDiscount
  def initialize(percent, message)
     = (100 - percent) / 100.0
    @message = message
  end

  def apply(line_item)
    line_item.change_line_price(line_item.line_price * , message: @message)
  end
end

CAMPAIGNS = [
  BundleDiscount.new(
    :all,
    nil,
    nil,
    PercentageDiscount.new(
      30,
      "TEST A bundle offer"
    ),
    true,
    [{:type => "pid", :qualifiers => ["2339250048"], :quantity => "1"},	{:type => "pid", :qualifiers => ["7462945667198","738321258462"], :quantity => "1"}]
  ),
  BundleDiscount.new(
    :all,
    nil,
    nil,
    PercentageDiscount.new(
      30,
      "TEST B bundle offer"
    ),
    true,
    [{:type => "pid", :qualifiers => ["45603345126"], :quantity => "1"},	{:type => "pid", :qualifiers => ["369131353","8859257937054","119250048"], :quantity => "1"}]
  )
].freeze

CAMPAIGNS.each do |campaign|
  campaign.run_with_hooks(Input.cart)
end

Output.cart = Input.cart