diff --git a/backend/app/views/spree/admin/promotions/rules/_minimum_quantity.html.erb b/backend/app/views/spree/admin/promotions/rules/_minimum_quantity.html.erb new file mode 100644 index 00000000000..e48fba7f1b7 --- /dev/null +++ b/backend/app/views/spree/admin/promotions/rules/_minimum_quantity.html.erb @@ -0,0 +1,5 @@ +
+ <% field_name = "#{param_prefix}[preferred_minimum_quantity]" %> + <%= label_tag field_name, promotion_rule.model_name.human %> + <%= number_field_tag field_name, promotion_rule.preferred_minimum_quantity, class: "fullwidth", min: 1 %> +
diff --git a/core/app/models/spree/promotion/rules/minimum_quantity.rb b/core/app/models/spree/promotion/rules/minimum_quantity.rb new file mode 100644 index 00000000000..1ffc75726ab --- /dev/null +++ b/core/app/models/spree/promotion/rules/minimum_quantity.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Spree + class Promotion + module Rules + # Promotion rule for ensuring an order contains a minimum quantity of + # actionable items. + # + # This promotion rule is only compatible with the "all" match policy. It + # doesn't make a lot of sense to use it without that policy as it reduces + # it to a simple quantity check across the entire order which would be + # better served by an item total rule. + class MinimumQuantity < PromotionRule + validates :preferred_minimum_quantity, numericality: { only_integer: true, greater_than: 0 } + + preference :minimum_quantity, :integer, default: 1 + + # What type of objects we should run our eligiblity checks against. In + # this case, our rule only applies to an entire order. + # + # @param promotable [Spree::Order,Spree::LineItem] + # @return [Boolean] true if promotable is a Spree::Order, false + # otherwise + def applicable?(promotable) + promotable.is_a?(Spree::Order) + end + + # Will look at all of the "actionable" line items in the order and + # determine if the sum of their quantity is greater than the minimum. + # + # "Actionable" items are ones where they pass the "actionable?" check of + # all rules on the promotion. (e.g.: Match product/taxon when one of + # those rules is present.) + # + # When false is returned, the reason will be included in the + # `eligibility_errors` object. + # + # @param order [Spree::Order] the order we want to check eligibility on + # @param _options [Hash] ignored + # @return [Boolean] true if promotion is eligible, false otherwise + def eligible?(order, _options = {}) + actionable_line_items = order.line_items.select do |line_item| + promotion.rules.all? { _1.actionable?(line_item) } + end + + if actionable_line_items.sum(&:quantity) < preferred_minimum_quantity + eligibility_errors.add( + :base, + eligibility_error_message(:quantity_less_than_minimum, count: preferred_minimum_quantity), + error_code: :quantity_less_than_minimum + ) + end + + eligibility_errors.empty? + end + end + end + end +end diff --git a/core/config/locales/en.yml b/core/config/locales/en.yml index 7237e213402..7182fcdd031 100644 --- a/core/config/locales/en.yml +++ b/core/config/locales/en.yml @@ -217,6 +217,8 @@ en: description: Order total meets these criteria spree/promotion/rules/landing_page: description: Customer must have visited the specified page + spree/promotion/rules/minimum_quantity: + description: Order contains minimum quantity of applicable items spree/promotion/rules/nth_order: description: Apply a promotion to every nth order a user has completed. form_text: 'Apply this promotion on the users Nth order: ' @@ -652,6 +654,7 @@ en: spree/promotion/rules/first_repeat_purchase_since: First Repeat Purchase Since spree/promotion/rules/item_total: Item Total spree/promotion/rules/landing_page: Landing Page + spree/promotion/rules/minimum_quantity: Minimum Quantity 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) @@ -1533,6 +1536,9 @@ en: no_user_or_email_specified: You need to login or provide your email before applying this coupon code. no_user_specified: You need to login before applying this coupon code. not_first_order: This coupon code can only be applied to your first order. + quantity_less_than_minimum: + one: You need to add a least 1 applicable item to your order. + other: You need to add a least %{count} applicable items to your order. email: Email empty: Empty empty_cart: Empty Cart diff --git a/core/lib/spree/app_configuration.rb b/core/lib/spree/app_configuration.rb index 49baffcc554..22988d6bf76 100644 --- a/core/lib/spree/app_configuration.rb +++ b/core/lib/spree/app_configuration.rb @@ -665,6 +665,7 @@ def environment Spree::Promotion::Rules::UserLoggedIn Spree::Promotion::Rules::OneUsePerUser Spree::Promotion::Rules::Taxon + Spree::Promotion::Rules::MinimumQuantity Spree::Promotion::Rules::NthOrder Spree::Promotion::Rules::OptionValue Spree::Promotion::Rules::FirstRepeatPurchaseSince diff --git a/core/spec/models/spree/promotion/rules/minimum_quantity_spec.rb b/core/spec/models/spree/promotion/rules/minimum_quantity_spec.rb new file mode 100644 index 00000000000..faf9d8f9c24 --- /dev/null +++ b/core/spec/models/spree/promotion/rules/minimum_quantity_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Spree::Promotion::Rules::MinimumQuantity do + subject(:quantity_rule) { described_class.new(preferred_minimum_quantity: 2) } + + describe "#valid?" do + let(:promotion) { build(:promotion) } + + before { promotion.rules << quantity_rule } + + it { is_expected.to be_valid } + + context "when minimum quantity is zero" do + subject(:quantity_rule) { described_class.new(preferred_minimum_quantity: 0) } + + it { is_expected.not_to be_valid } + end + end + + describe "#applicable?" do + subject { quantity_rule.applicable?(promotable) } + + context "when promotable is an order" do + let(:promotable) { Spree::Order.new } + + it { is_expected.to be true } + end + + context "when promotable is a line item" do + let(:promotable) { Spree::LineItem.new } + + it { is_expected.to be false } + end + end + + describe "#eligible?" do + subject { quantity_rule.eligible?(order) } + + let(:order) { + create( + :order_with_line_items, + line_items_count: line_items.length, + line_items_attributes: line_items + ) + } + let(:promotion) { build(:promotion) } + + before { promotion.rules << quantity_rule } + + context "when only the quantity rule is applied" do + context "when the quantity is less than the minimum" do + let(:line_items) { [{ quantity: 1 }] } + + it { is_expected.to be false } + end + + context "when the quantity is equal to the minimum" do + let(:line_items) { [{ quantity: 2 }] } + + it { is_expected.to be true } + end + + context "when the quantity is greater than the minimum" do + let(:line_items) { [{ quantity: 4 }] } + + it { is_expected.to be true } + end + end + + context "when another rule limits the applicable items" do + let(:variant_1) { create(:variant) } + let(:variant_2) { create(:variant) } + let(:variant_3) { create(:variant) } + + let(:product_rule) { + Spree::Promotion::Rules::Product.new( + products: [variant_1.product, variant_2.product], + preferred_match_policy: "any" + ) + } + + before { promotion.rules << product_rule } + + context "when the applicable quantity is less than the minimum" do + let(:line_items) do + [ + { variant: variant_1, quantity: 1 }, + { variant: variant_3, quantity: 1 } + ] + end + + it { is_expected.to be false } + end + + context "when the applicable quantity is greater than the minimum" do + let(:line_items) do + [ + { variant: variant_1, quantity: 1 }, + { variant: variant_2, quantity: 1 }, + { variant: variant_3, quantity: 1 } + ] + end + + it { is_expected.to be true } + end + end + end +end