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 @@ +
+ +
+
<%= label_tag nil, Spree::Product.model_name.human %>
+
<%= label_tag nil, plural_resource_name(Spree::OptionValue) %>
+
+ + <%= content_tag :div, nil, class: "js-promo-rule-option-values", + data: { :'original-option-values' => promotion_rule.preferred_eligible_values } %> + + +
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. -