diff --git a/backend/app/views/spree/admin/promotions/rules/_line_item_option_value.html.erb b/backend/app/views/spree/admin/promotions/rules/_line_item_option_value.html.erb
new file mode 100644
index 00000000000..99ca90d34af
--- /dev/null
+++ b/backend/app/views/spree/admin/promotions/rules/_line_item_option_value.html.erb
@@ -0,0 +1,12 @@
+
diff --git a/backend/app/views/spree/admin/promotions/rules/_line_item_product.html.erb b/backend/app/views/spree/admin/promotions/rules/_line_item_product.html.erb
new file mode 100644
index 00000000000..972486873b6
--- /dev/null
+++ b/backend/app/views/spree/admin/promotions/rules/_line_item_product.html.erb
@@ -0,0 +1,18 @@
+
+
+
+ <%= label_tag "#{param_prefix}_product_ids_string", t('spree.product_rule.choose_products') %>
+ <%= hidden_field_tag "#{param_prefix}[product_ids_string]", promotion_rule.product_ids.join(","), class: "product_picker fullwidth" %>
+
+
+
+
+ <%= label_tag("#{param_prefix}_preferred_match_policy", promotion_rule.class.human_attribute_name(:preferred_match_policy)) %>
+ <%= select_tag(
+ "#{param_prefix}[preferred_match_policy]",
+ options_for_select(I18n.t("spree.promotion_rules.line_item_product.match_policies").to_a.map(&:reverse), promotion_rule.preferred_match_policy),
+ class: "custom-select fullwidth"
+ ) %>
+
+
+
diff --git a/backend/app/views/spree/admin/promotions/rules/_line_item_taxon.html.erb b/backend/app/views/spree/admin/promotions/rules/_line_item_taxon.html.erb
new file mode 100644
index 00000000000..c06d03c79d8
--- /dev/null
+++ b/backend/app/views/spree/admin/promotions/rules/_line_item_taxon.html.erb
@@ -0,0 +1,14 @@
+
+ <%= label_tag "#{param_prefix}_taxon_ids_string", t("spree.taxon_rule.choose_taxons") %>
+ <%= hidden_field_tag "#{param_prefix}[taxon_ids_string]", promotion_rule.taxon_ids.join(","), class: "taxon_picker fullwidth", id: "product_taxon_ids" %>
+
+
+
+ <%= label_tag("#{param_prefix}_preferred_match_policy", promotion_rule.class.human_attribute_name(:preferred_match_policy)) %>
+ <%= select_tag(
+ "#{param_prefix}[preferred_match_policy]",
+ options_for_select(I18n.t("spree.promotion_rules.line_item_taxon.match_policies").to_a.map(&:reverse), promotion_rule.preferred_match_policy),
+ class: "custom-select fullwidth",
+ ) %>
+
+
diff --git a/backend/spec/features/admin/promotion_adjustments_spec.rb b/backend/spec/features/admin/promotion_adjustments_spec.rb
index 383a4a70f29..342143bcd77 100644
--- a/backend/spec/features/admin/promotion_adjustments_spec.rb
+++ b/backend/spec/features/admin/promotion_adjustments_spec.rb
@@ -118,7 +118,7 @@
click_button "Create"
expect(page).to have_title("SAVE SAVE SAVE - Promotions")
- select "Product(s)", from: "Discount Rules"
+ select "Line Item Product(s)", from: "Discount Rules"
within("#rule_fields") { click_button "Add" }
select2_search "RoR Mug", from: "Choose products"
within('#rule_fields') { click_button "Update" }
@@ -136,7 +136,7 @@
expect(promotion.codes.first).to be_nil
first_rule = promotion.rules.first
- expect(first_rule.class).to eq(Spree::Promotion::Rules::Product)
+ expect(first_rule.class).to eq(Spree::Promotion::Rules::LineItemProduct)
expect(first_rule.products.map(&:name)).to include("RoR Mug")
first_action = promotion.actions.first
diff --git a/backend/spec/features/admin/promotions/product_rule_spec.rb b/backend/spec/features/admin/promotions/product_rule_spec.rb
index 2b6d909c352..11b1ec081fa 100644
--- a/backend/spec/features/admin/promotions/product_rule_spec.rb
+++ b/backend/spec/features/admin/promotions/product_rule_spec.rb
@@ -18,7 +18,7 @@ def add_promotion_rule_of_type(type)
background do
visit spree.edit_admin_promotion_path(promotion)
- add_promotion_rule_of_type("Product(s)")
+ add_promotion_rule_of_type("Order Product(s)")
end
it "can select by product sku" do
diff --git a/core/app/models/spree/calculator/distributed_amount.rb b/core/app/models/spree/calculator/distributed_amount.rb
index a472a737591..445a0771f49 100644
--- a/core/app/models/spree/calculator/distributed_amount.rb
+++ b/core/app/models/spree/calculator/distributed_amount.rb
@@ -15,18 +15,18 @@ class Calculator::DistributedAmount < Calculator
def compute_line_item(line_item)
return 0 unless line_item
return 0 unless preferred_currency.casecmp(line_item.currency).zero?
- return 0 unless calculable.promotion.line_item_actionable?(line_item.order, line_item)
+ return 0 unless calculable.promotion.line_item_eligible?(line_item)
Spree::DistributedAmountsHandler.new(
- actionable_line_items(line_item.order),
+ eligible_line_items(line_item.order),
preferred_amount
).amount(line_item)
end
private
- def actionable_line_items(order)
+ def eligible_line_items(order)
order.line_items.select do |line_item|
- calculable.promotion.line_item_actionable?(order, line_item)
+ calculable.promotion.line_item_eligible?(line_item)
end
end
end
diff --git a/core/app/models/spree/promotion.rb b/core/app/models/spree/promotion.rb
index e48589dadbe..992e3c661a0 100644
--- a/core/app/models/spree/promotion.rb
+++ b/core/app/models/spree/promotion.rb
@@ -211,20 +211,14 @@ def usage_count(excluded_orders: [])
end
def line_item_actionable?(order, line_item, promotion_code: nil)
- return false if blacklisted?(line_item)
+ line_item_eligible?(line_item, promotion_code: promotion_code)
+ end
+ deprecate line_item_actionable?: :line_item_eligible?, deprecator: Spree::Deprecation
- if eligible?(order, promotion_code: promotion_code)
- rules = eligible_rules(order)
- if rules.blank?
- true
- else
- rules.send(match_all? ? :all? : :any?) do |rule|
- rule.actionable? line_item
- end
- end
- else
- false
- end
+ def line_item_eligible?(line_item, promotion_code: nil)
+ !blacklisted?(line_item) &&
+ !!eligible_rules(line_item) &&
+ deprecated_line_item_actionable?(line_item, promotion_code: promotion_code)
end
def used_by?(user, excluded_orders = [])
@@ -252,6 +246,31 @@ def remove_from(order)
private
+ def deprecated_line_item_actionable?(line_item, promotion_code: {})
+ if eligible?(line_item.order, promotion_code: promotion_code)
+ rules = eligible_rules(line_item.order)
+ if rules.blank?
+ true
+ else
+ rules.send(match_all? ? :all? : :any?) do |rule|
+ if rule.respond_to?(:actionable?)
+ Spree::Deprecation.warn(
+ <<~WARN
+ The API of promotion rules has changed. Rather than specifying "actionable?" on your rule, create a new rule
+ that is applicable to line items and move the logic in your `actionable?` method to that rule's `eligible?` method.
+ WARN
+ )
+ rule.actionable? line_item
+ else
+ true
+ end
+ end
+ end
+ else
+ false
+ end
+ end
+
def blacklisted?(promotable)
case promotable
when Spree::LineItem
diff --git a/core/app/models/spree/promotion/actions/create_item_adjustments.rb b/core/app/models/spree/promotion/actions/create_item_adjustments.rb
index 19a3bf5b10f..3ed12341bc5 100644
--- a/core/app/models/spree/promotion/actions/create_item_adjustments.rb
+++ b/core/app/models/spree/promotion/actions/create_item_adjustments.rb
@@ -35,7 +35,7 @@ def perform(payload = {})
# item_total and ship_total
def compute_amount(adjustable)
order = adjustable.is_a?(Order) ? adjustable : adjustable.order
- return 0 unless promotion.line_item_actionable?(order, adjustable)
+ return 0 unless promotion.line_item_eligible?(adjustable)
promotion_amount = calculator.compute(adjustable)
promotion_amount ||= BigDecimal(0)
promotion_amount = promotion_amount.abs
@@ -89,7 +89,7 @@ def ensure_action_has_calculator
def line_items_to_adjust(promotion, order)
order.line_items.select do |line_item|
line_item.adjustments.none? { |adjustment| adjustment.source == self } &&
- promotion.line_item_actionable?(order, line_item)
+ promotion.line_item_eligible?(line_item)
end
end
end
diff --git a/core/app/models/spree/promotion/actions/create_quantity_adjustments.rb b/core/app/models/spree/promotion/actions/create_quantity_adjustments.rb
index 063dbd33478..60ff65e8348 100644
--- a/core/app/models/spree/promotion/actions/create_quantity_adjustments.rb
+++ b/core/app/models/spree/promotion/actions/create_quantity_adjustments.rb
@@ -61,7 +61,7 @@ def compute_amount(line_item)
adjustment_amount = adjustment_amount.abs
order = line_item.order
- line_items = actionable_line_items(order)
+ line_items = eligible_line_items(order)
actioned_line_items = order.line_item_adjustments.reload.
select { |adjustment| adjustment.source == self && adjustment.amount < 0 }.
@@ -83,9 +83,9 @@ def compute_amount(line_item)
private
- def actionable_line_items(order)
+ def eligible_line_items(order)
order.line_items.select do |item|
- promotion.line_item_actionable? order, item
+ promotion.line_item_eligible? item
end
end
diff --git a/core/app/models/spree/promotion/rules/line_item_option_value.rb b/core/app/models/spree/promotion/rules/line_item_option_value.rb
new file mode 100644
index 00000000000..b639c804f5c
--- /dev/null
+++ b/core/app/models/spree/promotion/rules/line_item_option_value.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Spree
+ class Promotion < Spree::Base
+ module Rules
+ class LineItemOptionValue < PromotionRule
+ preference :eligible_values, :hash
+
+ def applicable?(promotable)
+ promotable.is_a?(Spree::LineItem)
+ end
+
+ def eligible?(line_item, _options = {})
+ pid = line_item.product.id
+ ovids = line_item.variant.option_values.pluck(:id)
+
+ product_ids.include?(pid) && (value_ids(pid) & ovids).present?
+ end
+
+ def preferred_eligible_values
+ values = preferences[:eligible_values] || {}
+ Hash[values.keys.map(&:to_i).zip(
+ values.values.map do |value|
+ (value.is_a?(Array) ? value : value.split(",")).map(&:to_i)
+ end
+ )]
+ end
+
+ private
+
+ def product_ids
+ preferred_eligible_values.keys
+ end
+
+ def value_ids(product_id)
+ preferred_eligible_values[product_id]
+ end
+ end
+ end
+ end
+end
diff --git a/core/app/models/spree/promotion/rules/line_item_product.rb b/core/app/models/spree/promotion/rules/line_item_product.rb
new file mode 100644
index 00000000000..403e37ca833
--- /dev/null
+++ b/core/app/models/spree/promotion/rules/line_item_product.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Spree
+ class Promotion < Spree::Base
+ module Rules
+ # A rule to apply a promotion only to line items with or without a chosen product
+ class LineItemProduct < Spree::PromotionRule
+ MATCH_POLICIES = %w(include exclude)
+
+ has_many :product_promotion_rules,
+ dependent: :destroy,
+ foreign_key: :promotion_rule_id,
+ class_name: "Spree::ProductPromotionRule"
+ has_many :products,
+ class_name: "Spree::Product",
+ through: :product_promotion_rules
+
+ preference :match_policy, :string, default: MATCH_POLICIES.first
+
+ def applicable?(promotable)
+ promotable.is_a?(Spree::LineItem)
+ end
+
+ def eligible?(line_item, _options = {})
+ if inverse?
+ !product_ids.include?(line_item.variant.product_id)
+ else
+ product_ids.include?(line_item.variant.product_id)
+ end
+ end
+
+ def product_ids_string
+ product_ids.join(",")
+ end
+
+ def product_ids_string=(product_ids)
+ self.product_ids = product_ids.to_s.split(",").map(&:strip)
+ end
+
+ private
+
+ def inverse?
+ preferred_match_policy == "exclude"
+ end
+ end
+ end
+ end
+end
diff --git a/core/app/models/spree/promotion/rules/line_item_taxon.rb b/core/app/models/spree/promotion/rules/line_item_taxon.rb
new file mode 100644
index 00000000000..cc834dd8250
--- /dev/null
+++ b/core/app/models/spree/promotion/rules/line_item_taxon.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Spree
+ class Promotion < Spree::Base
+ module Rules
+ class LineItemTaxon < PromotionRule
+ has_many :promotion_rule_taxons, class_name: 'Spree::PromotionRuleTaxon', foreign_key: :promotion_rule_id,
+ dependent: :destroy
+ has_many :taxons, through: :promotion_rule_taxons, class_name: 'Spree::Taxon'
+
+ MATCH_POLICIES = %w(include exclude)
+
+ validates_inclusion_of :preferred_match_policy, in: MATCH_POLICIES
+
+ preference :match_policy, :string, default: MATCH_POLICIES.first
+ def applicable?(promotable)
+ promotable.is_a?(Spree::LineItem)
+ end
+
+ def eligible?(line_item, _options = {})
+ found = Spree::Classification.where(
+ product_id: line_item.variant.product_id,
+ taxon_id: rule_taxon_ids_with_children
+ ).exists?
+
+ case preferred_match_policy
+ when 'include'
+ found
+ when 'exclude'
+ !found
+ else
+ raise "unexpected match policy: #{preferred_match_policy.inspect}"
+ end
+ end
+
+ def taxon_ids_string
+ taxons.pluck(:id).join(',')
+ end
+
+ def taxon_ids_string=(taxon_ids)
+ taxon_ids = taxon_ids.to_s.split(',').map(&:strip)
+ self.taxons = Spree::Taxon.find(taxon_ids)
+ end
+
+ private
+
+ # ids of taxons rules and taxons rules children
+ def rule_taxon_ids_with_children
+ taxons.flat_map { |taxon| taxon.self_and_descendants.ids }.uniq
+ end
+ end
+ end
+ end
+end
diff --git a/core/app/models/spree/promotion/rules/option_value.rb b/core/app/models/spree/promotion/rules/option_value.rb
index 04de936957a..fbdb5d8a36e 100644
--- a/core/app/models/spree/promotion/rules/option_value.rb
+++ b/core/app/models/spree/promotion/rules/option_value.rb
@@ -4,28 +4,18 @@ module Spree
class Promotion < Spree::Base
module Rules
class OptionValue < PromotionRule
- MATCH_POLICIES = %w(any)
- preference :match_policy, :string, default: MATCH_POLICIES.first
preference :eligible_values, :hash
def applicable?(promotable)
promotable.is_a?(Spree::Order)
end
- def eligible?(promotable, _options = {})
- case preferred_match_policy
- when 'any'
- promotable.line_items.any? { |item| actionable?(item) }
+ def eligible?(order, _options = {})
+ order.line_items.any? do |item|
+ LineItemOptionValue.new(preferred_eligible_values: preferred_eligible_values).eligible?(item)
end
end
- def actionable?(line_item)
- pid = line_item.product.id
- ovids = line_item.variant.option_values.pluck(:id)
-
- product_ids.include?(pid) && (value_ids(pid) & ovids).present?
- end
-
def preferred_eligible_values
values = preferences[:eligible_values] || {}
Hash[values.keys.map(&:to_i).zip(
@@ -34,16 +24,6 @@ def preferred_eligible_values
end
)]
end
-
- private
-
- def product_ids
- preferred_eligible_values.keys
- end
-
- def value_ids(product_id)
- preferred_eligible_values[product_id]
- end
end
end
end
diff --git a/core/app/models/spree/promotion/rules/product.rb b/core/app/models/spree/promotion/rules/product.rb
index c8888bc71ad..8b0294b8eec 100644
--- a/core/app/models/spree/promotion/rules/product.rb
+++ b/core/app/models/spree/promotion/rules/product.rb
@@ -56,17 +56,6 @@ def eligible?(order, _options = {})
eligibility_errors.empty?
end
- def actionable?(line_item)
- case preferred_match_policy
- when 'any', 'all'
- product_ids.include? line_item.variant.product_id
- when 'none'
- product_ids.exclude? line_item.variant.product_id
- else
- raise "unexpected match policy: #{preferred_match_policy.inspect}"
- end
- end
-
def product_ids_string
product_ids.join(',')
end
diff --git a/core/app/models/spree/promotion/rules/taxon.rb b/core/app/models/spree/promotion/rules/taxon.rb
index 16b459636d5..e88c0fbd657 100644
--- a/core/app/models/spree/promotion/rules/taxon.rb
+++ b/core/app/models/spree/promotion/rules/taxon.rb
@@ -48,22 +48,6 @@ def eligible?(order, _options = {})
eligibility_errors.empty?
end
- def actionable?(line_item)
- found = Spree::Classification.where(
- product_id: line_item.variant.product_id,
- taxon_id: rule_taxon_ids_with_children
- ).exists?
-
- case preferred_match_policy
- when 'any', 'all'
- found
- when 'none'
- !found
- else
- raise "unexpected match policy: #{preferred_match_policy.inspect}"
- end
- end
-
def taxon_ids_string
taxons.pluck(:id).join(',')
end
diff --git a/core/app/models/spree/promotion_rule.rb b/core/app/models/spree/promotion_rule.rb
index 5fe201b7955..79439c573a6 100644
--- a/core/app/models/spree/promotion_rule.rb
+++ b/core/app/models/spree/promotion_rule.rb
@@ -31,12 +31,6 @@ def eligible?(_promotable, _options = {})
raise NotImplementedError, "eligible? should be implemented in a sub-class of Spree::PromotionRule"
end
- # This states if a promotion can be applied to the specified line item
- # It is true by default, but can be overridden by promotion rules to provide conditions
- def actionable?(_line_item)
- true
- end
-
def eligibility_errors
@eligibility_errors ||= ActiveModel::Errors.new(self)
end
diff --git a/core/config/locales/en.yml b/core/config/locales/en.yml
index 3e1cec69b05..1263ee59b9f 100644
--- a/core/config/locales/en.yml
+++ b/core/config/locales/en.yml
@@ -226,12 +226,20 @@ en:
description: Only one use per user
spree/promotion/rules/option_value:
description: Order includes specified product(s) with matching option value(s)
+ spree/promotion/rules/line_item_option_value:
+ description: Line Item has specified product with matching option value
spree/promotion/rules/product:
description: Order includes specified product(s)
+ spree/promotion/rules/line_item_product:
+ description: Line item matches specified product(s)
+ preferred_match_policy: Match Policy
spree/promotion/rules/store:
description: Available only to the specified stores
spree/promotion/rules/taxon:
description: Order includes products with specified taxon(s)
+ spree/promotion/rules/line_item_taxon:
+ description: Line Item has product with specified taxon(s)
+ preferred_match_policy: Match Policy
spree/promotion/rules/user:
description: Available only to the specified users
spree/promotion/rules/user_logged_in:
@@ -655,8 +663,11 @@ en:
spree/promotion/rules/nth_order: Nth Order
spree/promotion/rules/one_use_per_user: One Use Per User
spree/promotion/rules/option_value: Option Value(s)
- spree/promotion/rules/product: Product(s)
- spree/promotion/rules/taxon: Taxon(s)
+ spree/promotion/rules/line_item_option_value: Line Item Option Value(s)
+ spree/promotion/rules/product: Order Product(s)
+ spree/promotion/rules/line_item_product: Line Item Product(s)
+ spree/promotion/rules/taxon: Order Taxon(s)
+ spree/promotion/rules/line_item_taxon: Line Item Taxon(s)
spree/promotion/rules/user: User
spree/promotion/rules/user_logged_in: User Logged In
spree/promotion/rules/user_role: User Role(s)
@@ -1867,6 +1878,15 @@ en:
all: Match all of these rules
any: Match any of these rules
promotion_rule: Promotion Rule
+ promotion_rules:
+ line_item_product:
+ match_policies:
+ include: Line item's product is one of the chosen products
+ exclude: Line item's product is NOT one of the chosen products
+ line_item_taxon:
+ match_policies:
+ include: Line item's product has one of the chosen taxons
+ exclude: Line item's product does not have one of the chosen taxons
promotion_successfully_created: Promotion has been successfully created!
promotion_total_changed_before_complete: One or more of the promotions on your
order have become ineligible and were removed. Please check the new order amounts
diff --git a/core/db/migrate/20220401085754_add_line_item_promotion_rules_to_existing_promotions.rb b/core/db/migrate/20220401085754_add_line_item_promotion_rules_to_existing_promotions.rb
new file mode 100644
index 00000000000..89d483775af
--- /dev/null
+++ b/core/db/migrate/20220401085754_add_line_item_promotion_rules_to_existing_promotions.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class AddLineItemPromotionRulesToExistingPromotions < ActiveRecord::Migration[5.2]
+ def up
+ Spree::Promotion::Rules::Product.all.each do |promotion_rule|
+ match_policy = promotion_rule.preferred_match_policy == "none" ? "inverse" : "normal"
+ promotion_rule.promotion.rules << Spree::Promotion::Rules::LineItemProduct.new(
+ preferred_match_policy: match_policy,
+ products: promotion_rule.products
+ )
+ end
+ Spree::Promotion::Rules::Taxon.all.each do |promotion_rule|
+ match_policy = promotion_rule.preferred_match_policy == "none" ? "inverse" : "normal"
+ promotion_rule.promotion.rules << Spree::Promotion::Rules::LineItemTaxon.new(
+ preferred_match_policy: match_policy,
+ taxons: promotion_rule.taxons
+ )
+ end
+ Spree::Promotion::Rules::OptionValue.all.each do |promotion_rule|
+ promotion_rule.promotion.rules << Spree::Promotion::Rules::LineItemOptionValue.new(
+ preferred_eligible_values: promotion_rule.preferred_eligible_values
+ )
+ end
+ end
+
+ def down
+ Spree::Promotion::Rules::LineItemOptionValue.destroy_all
+ Spree::Promotion::Rules::LineItemTaxon.destroy_all
+ Spree::Promotion::Rules::LineItemProduct.destroy_all
+ end
+end
diff --git a/core/lib/spree/app_configuration.rb b/core/lib/spree/app_configuration.rb
index d167d0c83f2..5802fff22f7 100644
--- a/core/lib/spree/app_configuration.rb
+++ b/core/lib/spree/app_configuration.rb
@@ -623,10 +623,13 @@ def environment
Spree::Promotion::Rules::User
Spree::Promotion::Rules::FirstOrder
Spree::Promotion::Rules::UserLoggedIn
+ Spree::Promotion::Rules::LineItemProduct
Spree::Promotion::Rules::OneUsePerUser
Spree::Promotion::Rules::Taxon
+ Spree::Promotion::Rules::LineItemTaxon
Spree::Promotion::Rules::NthOrder
Spree::Promotion::Rules::OptionValue
+ Spree::Promotion::Rules::LineItemOptionValue
Spree::Promotion::Rules::FirstRepeatPurchaseSince
Spree::Promotion::Rules::UserRole
Spree::Promotion::Rules::Store
diff --git a/core/spec/models/spree/calculator/distributed_amount_spec.rb b/core/spec/models/spree/calculator/distributed_amount_spec.rb
index 17f9eee82fc..86d62d7caef 100644
--- a/core/spec/models/spree/calculator/distributed_amount_spec.rb
+++ b/core/spec/models/spree/calculator/distributed_amount_spec.rb
@@ -31,14 +31,13 @@
let(:first_product) { order.line_items.first.product }
before do
- rule = Spree::Promotion::Rules::Product.create!(
- promotion: promotion,
- product_promotion_rules: [
- Spree::ProductPromotionRule.new(product: first_product),
- ],
+ order_rule = Spree::Promotion::Rules::Product.new(
+ products: [first_product],
)
- promotion.rules << rule
- promotion.save!
+ line_item_rule = Spree::Promotion::Rules::LineItemProduct.new(
+ products: [first_product],
+ )
+ promotion.rules << order_rule << line_item_rule
order.recalculate
end
diff --git a/core/spec/models/spree/promotion/actions/create_item_adjustments_spec.rb b/core/spec/models/spree/promotion/actions/create_item_adjustments_spec.rb
index ee842f535f2..5b138a27ba3 100644
--- a/core/spec/models/spree/promotion/actions/create_item_adjustments_spec.rb
+++ b/core/spec/models/spree/promotion/actions/create_item_adjustments_spec.rb
@@ -10,10 +10,11 @@ module Spree
let(:action) { promotion.actions.first! }
let(:line_item) { order.line_items.to_a.first }
let(:payload) { { order: order, promotion: promotion } }
+ let(:rules) { [] }
before do
- allow(action).to receive(:promotion).and_return(promotion)
promotion.promotion_actions = [action]
+ promotion.promotion_rules = rules
end
context "#perform" do
@@ -47,12 +48,10 @@ module Spree
end
context "with products rules" do
- let(:rule) { double Spree::Promotion::Rules::Product }
-
- before { allow(promotion).to receive(:eligible_rules) { [rule] } }
-
- context "when the rule is actionable" do
- before { allow(rule).to receive(:actionable?).and_return(true) }
+ let(:rule) { Spree::Promotion::Rules::LineItemProduct.new }
+ let(:rules) { [rule] }
+ context "when the rule is eligible" do
+ before { allow(rule).to receive(:eligible?).and_return(true) }
it "creates an adjustment" do
expect {
@@ -65,8 +64,8 @@ module Spree
end
end
- context "when the rule is not actionable" do
- before { allow(rule).to receive(:actionable?).and_return(false) }
+ context "when the rule is not eligible" do
+ before { allow(rule).to receive(:eligible?).and_return(false) }
it "does not create an adjustment" do
expect {
@@ -95,7 +94,7 @@ module Spree
context "#compute_amount" do
before { promotion.promotion_actions = [action] }
- context "when the adjustable is actionable" do
+ context "when the adjustable is eligible" do
it "calls compute on the calculator" do
expect(action.calculator).to receive(:compute).with(line_item).and_call_original
action.compute_amount(line_item)
@@ -113,8 +112,8 @@ module Spree
end
end
- context "when the adjustable is not actionable" do
- before { allow(promotion).to receive(:line_item_actionable?) { false } }
+ context "when the adjustable is not eligible" do
+ before { allow(promotion).to receive(:line_item_eligible?) { false } }
it 'returns 0' do
expect(action.compute_amount(line_item)).to eql(0)
diff --git a/core/spec/models/spree/promotion/integration_spec.rb b/core/spec/models/spree/promotion/integration_spec.rb
new file mode 100644
index 00000000000..275b0270d94
--- /dev/null
+++ b/core/spec/models/spree/promotion/integration_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Promotion System" do
+ context "A promotion that creates line item adjustments" do
+ let(:shirt) { create(:product) }
+ let(:pants) { create(:product) }
+ let(:promotion) { create(:promotion, name: "20% off Shirts", apply_automatically: true) }
+ let(:order) { create(:order) }
+
+ before do
+ promotion.rules << rule
+ promotion.actions << action
+ order.contents.add(shirt.master, 1)
+ order.contents.add(pants.master, 1)
+ end
+
+ context "with an order-level rule" do
+ let(:rule) { Spree::Promotion::Rules::Product.new(products: [shirt]) }
+
+ context "with an order level action" do
+ let(:calculator) { Spree::Calculator::FlatPercentItemTotal.new(preferred_flat_percent: 20) }
+ let(:action) { Spree::Promotion::Actions::CreateAdjustment.new(calculator: calculator) }
+
+ it "creates one order-level adjustment" do
+ expect(order.adjustments.length).to eq(1)
+ expect(order.total).to eq(31.98)
+ expect(order.item_total).to eq(39.98)
+ # This is wrong! But order level adjustments can't work any other way
+ expect(order.item_total_before_tax).to eq(39.98)
+ expect(order.line_items.flat_map(&:adjustments)).to be_empty
+ end
+ end
+
+ context "with an line item level action" do
+ let(:calculator) { Spree::Calculator::PercentOnLineItem.new(preferred_percent: 20) }
+ let(:action) { Spree::Promotion::Actions::CreateItemAdjustments.new(calculator: calculator) }
+
+ it "creates one order-level adjustment" do
+ expect(order.adjustments).to be_empty
+ expect(order.total).to eq(31.98)
+ expect(order.item_total).to eq(39.98)
+ expect(order.item_total_before_tax).to eq(31.98)
+ expect(order.line_items.flat_map(&:adjustments).length).to eq(2)
+ end
+ end
+ end
+
+ context "with a line-item level rule" do
+ let(:rule) { Spree::Promotion::Rules::LineItemProduct.new(products: [shirt]) }
+
+ context "with an order level action" do
+ let(:calculator) { Spree::Calculator::FlatPercentItemTotal.new(preferred_flat_percent: 20) }
+ let(:action) { Spree::Promotion::Actions::CreateAdjustment.new(calculator: calculator) }
+
+ it "creates one order-level adjustment" do
+ # Whoops - this works because line item level rules don't affect order-level actions :(
+ expect(order.adjustments.length).to eq(1)
+ expect(order.total).to eq(31.98)
+ expect(order.item_total).to eq(39.98)
+ # This is wrong! But order level adjustments can't work any other way
+ expect(order.item_total_before_tax).to eq(39.98)
+ expect(order.line_items.flat_map(&:adjustments)).to be_empty
+ end
+ end
+
+ context "with an line item level action" do
+ let(:calculator) { Spree::Calculator::PercentOnLineItem.new(preferred_percent: 20) }
+ let(:action) { Spree::Promotion::Actions::CreateItemAdjustments.new(calculator: calculator) }
+
+ it "creates one line item level adjustment" do
+ expect(order.adjustments).to be_empty
+ expect(order.total).to eq(35.98)
+ expect(order.item_total).to eq(39.98)
+ expect(order.item_total_before_tax).to eq(35.98)
+ expect(order.line_items.flat_map(&:adjustments).length).to eq(1)
+ end
+ end
+ end
+ end
+end
diff --git a/core/spec/models/spree/promotion/rules/line_item_product_spec.rb b/core/spec/models/spree/promotion/rules/line_item_product_spec.rb
new file mode 100644
index 00000000000..835e1371131
--- /dev/null
+++ b/core/spec/models/spree/promotion/rules/line_item_product_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Spree::Promotion::Rules::LineItemProduct, type: :model do
+ let(:rule) { described_class.new(rule_options) }
+ let(:rule_options) { {} }
+
+ context "#eligible?(line_item)" do
+ let(:rule_line_item) { Spree::LineItem.new(product: rule_product) }
+ let(:other_line_item) { Spree::LineItem.new(product: other_product) }
+
+ let(:rule_options) { super().merge(products: [rule_product]) }
+ let(:rule_product) { mock_model(Spree::Product) }
+ let(:other_product) { mock_model(Spree::Product) }
+
+ it "should be eligible if there are no products" do
+ expect(rule).to be_eligible(rule_line_item)
+ end
+
+ subject { rule.eligible?(line_item, {}) }
+
+ context "for product in rule" do
+ let(:line_item) { rule_line_item }
+ it { is_expected.to be_truthy }
+ end
+
+ context "for product not in rule" do
+ let(:line_item) { other_line_item }
+ it { is_expected.to be_falsey }
+ end
+
+ context "if match policy is inverse" do
+ let(:rule_options) { super().merge(preferred_match_policy: "exclude") }
+
+ context "for product in rule" do
+ let(:line_item) { rule_line_item }
+ it { is_expected.to be_falsey }
+ end
+
+ context "for product not in rule" do
+ let(:line_item) { other_line_item }
+ it { is_expected.to be_truthy }
+ end
+ end
+ end
+end
diff --git a/core/spec/models/spree/promotion/rules/line_item_taxon_spec.rb b/core/spec/models/spree/promotion/rules/line_item_taxon_spec.rb
new file mode 100644
index 00000000000..20468e347d4
--- /dev/null
+++ b/core/spec/models/spree/promotion/rules/line_item_taxon_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Spree::Promotion::Rules::LineItemTaxon, type: :model do
+ let(:taxon) { create :taxon, name: 'first' }
+ let(:taxon2) { create :taxon, name: 'second' }
+ let(:order) { create :order_with_line_items }
+ let(:product) { order.products.first }
+
+ let(:rule) do
+ described_class.create!(promotion: create(:promotion))
+ end
+
+ describe '#eligible?' do
+ let(:line_item) { order.line_items.first! }
+ let(:order) { create :order_with_line_items }
+ let(:taxon) { create :taxon, name: 'first' }
+
+ context 'with an invalid match policy' do
+ before do
+ rule.preferred_match_policy = 'invalid'
+ rule.save!(validate: false)
+ line_item.product.taxons << taxon
+ rule.taxons << taxon
+ end
+
+ it 'raises' do
+ expect {
+ rule.eligible?(line_item)
+ }.to raise_error('unexpected match policy: "invalid"')
+ end
+ end
+
+ context 'when a product has a taxon of a taxon rule' do
+ before do
+ product.taxons << taxon
+ rule.taxons << taxon
+ rule.save!
+ end
+
+ it 'is eligible' do
+ expect(rule).to be_eligible(line_item)
+ end
+ end
+
+ context 'when a product has a taxon child of a taxon rule' do
+ before do
+ taxon.children << taxon2
+ product.taxons << taxon2
+ rule.taxons << taxon
+ rule.save!
+ end
+
+ it 'is eligible' do
+ expect(rule).to be_eligible(line_item)
+ end
+
+ context "with 'exclude' match policy" do
+ before do
+ rule.update(preferred_match_policy: :exclude)
+ end
+
+ it "is not eligible" do
+ expect(rule).not_to be_eligible(line_item)
+ end
+ end
+ end
+
+ context 'when a product does not have taxon or child taxon of a taxon rule' do
+ before do
+ product.taxons << taxon2
+ rule.taxons << taxon
+ rule.save!
+ end
+
+ it 'is not eligible' do
+ expect(rule).not_to be_eligible(line_item)
+ end
+
+ context "with 'exclude' match policy" do
+ before do
+ rule.update(preferred_match_policy: :exclude)
+ end
+
+ it "is not eligible" do
+ expect(rule).to be_eligible(line_item)
+ end
+ end
+ end
+ end
+end
diff --git a/core/spec/models/spree/promotion/rules/option_value_spec.rb b/core/spec/models/spree/promotion/rules/option_value_spec.rb
index 26eef3780db..db01051c8fb 100644
--- a/core/spec/models/spree/promotion/rules/option_value_spec.rb
+++ b/core/spec/models/spree/promotion/rules/option_value_spec.rb
@@ -45,52 +45,4 @@
it { is_expected.to be false }
end
end
-
- describe "#actionable?" do
- let(:line_item) { create :line_item }
- let(:option_value_blue) do
- create(
- :option_value,
- name: 'Blue',
- presentation: 'Blue',
- option_type: create(
- :option_type,
- name: 'foo-colour',
- presentation: 'Colour'
- )
- )
- end
- let(:option_value_medium) do
- create(
- :option_value,
- name: 'Medium',
- presentation: 'M'
- )
- end
- before do
- line_item.variant.option_values << option_value_blue
- rule.preferred_eligible_values = Hash[product_id => option_value_ids]
- end
- subject { rule.actionable?(line_item) }
- context "when the line item has the correct product" do
- let(:product_id) { line_item.product.id }
- context "when all of the option values match" do
- let(:option_value_ids) { [option_value_blue.id] }
- it { is_expected.to be true }
- end
- context "when any of the option values match" do
- let(:option_value_ids) { [option_value_blue.id, option_value_medium.id] }
- it { is_expected.to be true }
- end
- context "when none of the option values match" do
- let(:option_value_ids) { [option_value_medium.id] }
- it { is_expected.to be false }
- end
- end
- context "when the line item's product doesn't match" do
- let(:product_id) { 99 }
- let(:option_value_ids) { [99] }
- it { is_expected.to be false }
- end
- end
end
diff --git a/core/spec/models/spree/promotion/rules/product_spec.rb b/core/spec/models/spree/promotion/rules/product_spec.rb
index afe73223aa0..7e400baa287 100644
--- a/core/spec/models/spree/promotion/rules/product_spec.rb
+++ b/core/spec/models/spree/promotion/rules/product_spec.rb
@@ -124,70 +124,4 @@
end
end
end
-
- describe '#actionable?' do
- subject do
- rule.actionable?(line_item)
- end
-
- let(:rule_line_item) { Spree::LineItem.new(product: rule_product) }
- let(:other_line_item) { Spree::LineItem.new(product: other_product) }
-
- let(:rule_options) { super().merge(products: [rule_product]) }
- let(:rule_product) { mock_model(Spree::Product) }
- let(:other_product) { mock_model(Spree::Product) }
-
- context "with 'any' match policy" do
- let(:rule_options) { super().merge(preferred_match_policy: 'any') }
-
- context 'for product in rule' do
- let(:line_item) { rule_line_item }
- it { is_expected.to be_truthy }
- end
-
- context 'for product not in rule' do
- let(:line_item) { other_line_item }
- it { is_expected.to be_falsey }
- end
- end
-
- context "with 'all' match policy" do
- let(:rule_options) { super().merge(preferred_match_policy: 'all') }
-
- context 'for product in rule' do
- let(:line_item) { rule_line_item }
- it { is_expected.to be_truthy }
- end
-
- context 'for product not in rule' do
- let(:line_item) { other_line_item }
- it { is_expected.to be_falsey }
- end
- end
-
- context "with 'none' match policy" do
- let(:rule_options) { super().merge(preferred_match_policy: 'none') }
-
- context 'for product in rule' do
- let(:line_item) { rule_line_item }
- it { is_expected.to be_falsey }
- end
-
- context 'for product not in rule' do
- let(:line_item) { other_line_item }
- it { is_expected.to be_truthy }
- end
- end
-
- context 'with an invalid match policy' do
- let(:rule_options) { super().merge(preferred_match_policy: 'invalid') }
- let(:line_item) { rule_line_item }
-
- it 'raises' do
- expect {
- rule.actionable?(line_item)
- }.to raise_error('unexpected match policy: "invalid"')
- end
- end
- end
end
diff --git a/core/spec/models/spree/promotion/rules/taxon_spec.rb b/core/spec/models/spree/promotion/rules/taxon_spec.rb
index 322be092b4e..4c866595076 100644
--- a/core/spec/models/spree/promotion/rules/taxon_spec.rb
+++ b/core/spec/models/spree/promotion/rules/taxon_spec.rb
@@ -24,22 +24,6 @@
expect(rule).to be_eligible(order)
end
- context 'when order contains items from different taxons' do
- before do
- product.taxons << taxon
- rule.taxons << taxon
- end
-
- it 'should act on a product within the eligible taxon' do
- expect(rule).to be_actionable(order.line_items.last)
- end
-
- it 'should not act on a product in another taxon' do
- order.line_items << create(:line_item, product: create(:product, taxons: [taxon2]))
- expect(rule).not_to be_actionable(order.line_items.last)
- end
- end
-
context "when order does not have any prefered taxon" do
before { rule.taxons << taxon2 }
it { expect(rule).not_to be_eligible(order) }
@@ -157,62 +141,4 @@
end
end
end
-
- describe '#actionable?' do
- let(:line_item) { order.line_items.first! }
- let(:order) { create :order_with_line_items }
- let(:taxon) { create :taxon, name: 'first' }
-
- context 'with an invalid match policy' do
- before do
- rule.preferred_match_policy = 'invalid'
- rule.save!(validate: false)
- line_item.product.taxons << taxon
- rule.taxons << taxon
- end
-
- it 'raises' do
- expect {
- rule.eligible?(order)
- }.to raise_error('unexpected match policy: "invalid"')
- end
- end
-
- context 'when a product has a taxon of a taxon rule' do
- before do
- product.taxons << taxon
- rule.taxons << taxon
- rule.save!
- end
-
- it 'is actionable' do
- expect(rule).to be_actionable(line_item)
- end
- end
-
- context 'when a product has a taxon child of a taxon rule' do
- before do
- taxon.children << taxon2
- product.taxons << taxon2
- rule.taxons << taxon
- rule.save!
- end
-
- it 'is actionable' do
- expect(rule).to be_actionable(line_item)
- end
- end
-
- context 'when a product does not have taxon or child taxon of a taxon rule' do
- before do
- product.taxons << taxon2
- rule.taxons << taxon
- rule.save!
- end
-
- it 'is not actionable' do
- expect(rule).not_to be_actionable(line_item)
- end
- end
- end
end
diff --git a/core/spec/models/spree/promotion_spec.rb b/core/spec/models/spree/promotion_spec.rb
index 2b2294d572b..f03b46a3f4a 100644
--- a/core/spec/models/spree/promotion_spec.rb
+++ b/core/spec/models/spree/promotion_spec.rb
@@ -828,15 +828,21 @@
end
describe '#line_item_actionable?' do
- let(:order) { double Spree::Order }
- let(:line_item) { double Spree::LineItem }
- let(:true_rule) { stub_model Spree::PromotionRule, eligible?: true, applicable?: true, actionable?: true }
- let(:false_rule) { stub_model Spree::PromotionRule, eligible?: true, applicable?: true, actionable?: false }
+ let(:order) { line_item.order }
+ let(:line_item) { build(:line_item) }
+ let(:true_rule) { double eligible?: true, applicable?: true, actionable?: true }
+ let(:false_rule) { double eligible?: true, applicable?: true, actionable?: false }
let(:rules) { [] }
before do
- promotion.promotion_rules = rules
- promotion.promotion_actions = [Spree::PromotionAction.new]
+ allow(promotion).to receive(:rules) { rules }
+ allow(promotion).to receive(:actions) { [Spree::PromotionAction.new] }
+ end
+
+ around do |example|
+ Spree::Deprecation.silence do
+ example.run
+ end
end
subject { promotion.line_item_actionable? order, line_item }
@@ -880,6 +886,14 @@
let(:line_item) { build(:line_item) { |li| li.variant.product.promotionable = false } }
it { is_expected.not_to be }
end
+
+ context "if the promotion has ineligible line item rules" do
+ before do
+ expect(promotion).to receive(:line_item_eligible?) { false }
+ end
+
+ it { is_expected.to be false }
+ end
end
end
@@ -894,7 +908,7 @@
let(:promotion) { create(:promotion, per_code_usage_limit: 0) }
let(:promotion_code) { create(:promotion_code, promotion: promotion) }
- subject { promotion.line_item_actionable? order, line_item, promotion_code: promotion_code }
+ subject { promotion.line_item_eligible? line_item, promotion_code: promotion_code }
it "returns false" do
expect(subject).to eq false
@@ -903,6 +917,48 @@
end
end
+ describe "#line_item_eligible?" do
+ let(:line_item) { build(:line_item) }
+ let(:promotion) { create(:promotion, :with_action) }
+ let(:rules) { [] }
+
+ subject { promotion.line_item_eligible?(line_item) }
+
+ before do
+ promotion.promotion_rules = rules
+ end
+
+ it { is_expected.to be true }
+
+ context "if the line item's variant is not safelisted" do
+ let(:product) { build(:product, promotionable: false) }
+ let(:line_item) { build(:line_item, variant: product.master) }
+
+ it { is_expected.to be false }
+ end
+
+ context "if the promotion has inapplicable rules" do
+ let(:rule) { stub_model Spree::PromotionRule, eligible?: false, applicable?: false }
+ let(:rules) { [rule] }
+
+ it { is_expected.to be true }
+ end
+
+ context "if the promotion has applicable rules" do
+ let(:rule) { stub_model Spree::PromotionRule, eligible?: false, applicable?: true }
+ let(:rules) { [rule] }
+
+ it { is_expected.to be false }
+ end
+
+ context "if the promotion has applicable and eligible rules" do
+ let(:rule) { stub_model Spree::PromotionRule, eligible?: true, applicable?: true }
+ let(:rules) { [rule] }
+
+ it { is_expected.to be true }
+ end
+ end
+
# regression for https://github.com/spree/spree/issues/4059
# admin form posts the code and path as empty string
describe "normalize blank values for path" do
diff --git a/guides/source/developers/promotions/promotion-rules.html.md b/guides/source/developers/promotions/promotion-rules.html.md
index 6c3221a955b..e2507a50095 100644
--- a/guides/source/developers/promotions/promotion-rules.html.md
+++ b/guides/source/developers/promotions/promotion-rules.html.md
@@ -21,9 +21,14 @@ model][promotion-rules]:
- `ItemTotal`: Eligible if the order total (before any adjustments) is less than
or greater than a specified amount.
- `OneUsePerUser`: Eligible for use one time for each user.
-- `Product`: Eligible for specified products only.
-- `OptionValue`: Eligible for specified variants (product option values) only.
-- `Taxon`: Eligible for products with specified taxons.
+- `Product`: Order is eligible if it contains specified products.
+- `LineItemProduct`: Line Item is eligible if it has one of the specified products.
+- `OptionValue`: Order is eligible if it contains specified variants
+ (product option values) only.
+- `LineItemOptionValue`: Line Item is eligible if it has one of the
+ specified variants (product option values).
+- `Taxon`: Order is eligible if any product has the specified taxons.
+- `LineItemTaxon`: Line item is eligible if item's product has the specified taxons.
- `User`: Eligible for specified users.
- `UserRole`: Eligible for users with the specified user role.
- `UserLoggedIn`: Eligible for users who are logged in.
@@ -72,10 +77,6 @@ module Spree
def eligible?(order, options = {})
...
end
-
- def actionable?(line_item)
- ...
- end
...
```
@@ -84,14 +85,16 @@ Note that the `applicable?` and `eligible?` are required:
- `eligible?` should return `true` or `false` to indicate if the promotion is
eligible for an order.
- If your promotion supports discounts for some line items but not others,
- define `actionable?` to return `true` when the specified line item meets the
- criteria for the promotion. It should return `true` or `false` to indicate if
- this line item can have a line item adjustment carried out on it.
+ define a separate promotion rule that is `applicable?` for line items:
+
+ ```ruby
+ def applicable?(promotable)
+ promotable.is_a?(Spree::LineItem)
+ end
+ ```
+
+ This rule should contain the line item eligibility logic in its `eligible?` method.
-For example, if you are giving a promotion on specific products only,
-`eligible?` should return true if the order contains one of the products
-eligible for promotion, and `actionable?` should return true when the line item
-specified is one of the specific products for this promotion.
Note that you can retrieve the associated `Spree::Promotion` information by
calling the `promotion` method.
@@ -122,7 +125,7 @@ en:
models:
# The presentation name of the promotion rule
spree/promotion/rules/my_promotion_rule: My Promotion Rule
-
+
# If you used a custom error message
spree:
eligibility_errors:
@@ -131,4 +134,3 @@ en:
```
After a server restart, the new rule will be available from the Solidus admin promotion interface.
-
diff --git a/guides/source/users/promotions/promotion-rules.html.md b/guides/source/users/promotions/promotion-rules.html.md
index 19020aa4ae2..4398cc43574 100644
--- a/guides/source/users/promotions/promotion-rules.html.md
+++ b/guides/source/users/promotions/promotion-rules.html.md
@@ -18,10 +18,17 @@ creating one.
- **Item Total**: Eligible if the order total (before any adjustments) is less
than or greater than a specified amount.
- **One Use Per User**: Eligible for use one time per user.
-- **Product(s)**: Eligible for specified products only.
-- **Option Value(s)**: Eligible for specified variants (product option values)
- only.
-- **Taxon**: Eligible for products with specified taxons.
+- **Order Product(s)**: Order is eligible if it has (or does not have)
+ the specified products.
+- **Line Item Product(s)**: Line Items are eligible if they have (or do not have)
+ the specified products.
+- **Order Option Value(s)**: Order is eligible if it has specified variants
+ (product option values) only.
+- **Line Item Option Value(s)**: Line Items are eligible if they have specified
+ variants (product option values) only.
+- **Order Taxon**: Order is eligible if products with specified taxons are in the order.
+- **Line Item Taxon**: Line Items are eligible if they have products with specified
+ taxons.
- **User**: Eligible for specified users.
- **User Role**: Eligible for users with the specified user role.
- **User Logged In**: Eligible for users who are logged in.
@@ -36,6 +43,7 @@ Every time the promotion rules are re-checked, any promotional discounts are
recalculated as well. For example, if the customer added more items to the cart,
those new items could now be calculated against the promotion rules.
+Note that line-item level rules do not apply to order-level discounts.
## Rule matching
There are two types of rule matching in Solidus' promotion system: **Match all
@@ -48,4 +56,3 @@ of these rules** or **Match any of these rules**.
- **Match any of these rules**: This setting allows you to create more flexible
promotions. For example, if you wanted to give a discount to customers who
order your Tote Bag product *or* if it's their 5th order from your store.
-