From 220459a76573bd9395cf7b7e0c600962067c1a10 Mon Sep 17 00:00:00 2001 From: Adam Mueller Date: Tue, 24 Oct 2023 11:37:48 -0700 Subject: [PATCH] Add MinimumQuantity promotion rule This new rule allows for an easy way of providing "bulk" discounts through the promotion system. It will prevent a promotion from being applied until it meets a quantity threshold. The rule will also account for other rules that limit the applicable line items. (e.g.: Taxons, options values, etc.) Co-authored-by: Benjamin Wil --- .../rules/_minimum_quantity.html.erb | 5 + .../spree/promotion/rules/minimum_quantity.rb | 59 ++++++++++ core/config/locales/en.yml | 6 + core/lib/spree/app_configuration.rb | 1 + .../promotion/rules/minimum_quantity_spec.rb | 108 ++++++++++++++++++ 5 files changed, 179 insertions(+) create mode 100644 backend/app/views/spree/admin/promotions/rules/_minimum_quantity.html.erb create mode 100644 core/app/models/spree/promotion/rules/minimum_quantity.rb create mode 100644 core/spec/models/spree/promotion/rules/minimum_quantity_spec.rb 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..409a4edabde --- /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 + # applicable 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 "applicable" line items in the order and + # determine if the sum of their quantity is greater than the minimum. + # + # "Applicable" 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 = {}) + applicable_line_items = order.line_items.select do |line_item| + promotion.rules.all? { _1.actionable?(line_item) } + end + + if applicable_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..ee626bc9529 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..60c58df5467 --- /dev/null +++ b/core/spec/models/spree/promotion/rules/minimum_quantity_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +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(:carry_on) { create(:away_base_variant) } + let(:other_carry_on) { create(:away_base_variant) } + let(:everywhere_bag) { create(:product, :everywhere_bag).master } + + let(:product_rule) { + Spree::Promotion::Rules::Product.new( + products: [carry_on.product, other_carry_on.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: carry_on, quantity: 1 }, + { variant: everywhere_bag, 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: carry_on, quantity: 1 }, + { variant: other_carry_on, quantity: 1 }, + { variant: everywhere_bag, quantity: 1 } + ] + end + + it { is_expected.to be true } + end + end + end +end