diff --git a/friendly_promotions/app/models/solidus_friendly_promotions/rules/minimum_quantity.rb b/friendly_promotions/app/models/solidus_friendly_promotions/rules/minimum_quantity.rb new file mode 100644 index 00000000..819b0254 --- /dev/null +++ b/friendly_promotions/app/models/solidus_friendly_promotions/rules/minimum_quantity.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module SolidusFriendlyPromotions + 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 + include OrderLevelRule + + validates :preferred_minimum_quantity, numericality: {only_integer: true, greater_than: 0} + + preference :minimum_quantity, :integer, default: 1 + + # 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 that pass all eligibility checks of applicable rules. + # + # 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 + # @return [Boolean] true if promotion is eligible, false otherwise + def eligible?(order) + applicable_line_items = order.line_items.select do |line_item| + promotion.rules.select do |rule| + rule.applicable?(line_item) + end.all? { _1.eligible?(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 diff --git a/friendly_promotions/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_minimum_quantity.html.erb b/friendly_promotions/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_minimum_quantity.html.erb new file mode 100644 index 00000000..e48fba7f --- /dev/null +++ b/friendly_promotions/app/views/solidus_friendly_promotions/admin/promotion_rules/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/friendly_promotions/config/locales/en.yml b/friendly_promotions/config/locales/en.yml index edae25b4..ba15f0c5 100644 --- a/friendly_promotions/config/locales/en.yml +++ b/friendly_promotions/config/locales/en.yml @@ -91,6 +91,10 @@ en: solidus_friendly_promotions/rules/user_logged_in: no_user_specified: You need to login before applying this coupon code. solidus_friendly_promotions/rules/user_role: + solidus_friendly_promotions/rules/minimum_quantity: + 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." product_rule: choose_products: Choose products label: Order must contain %{select} these products @@ -157,6 +161,7 @@ en: solidus_friendly_promotions/rules/item_total: Item Total solidus_friendly_promotions/rules/discounted_item_total: Item Total after previous lanes solidus_friendly_promotions/rules/landing_page: Landing Page + solidus_friendly_promotions/rules/minimum_quantity: Minimum Quantity solidus_friendly_promotions/rules/nth_order: Nth Order solidus_friendly_promotions/rules/one_use_per_user: One Use Per User solidus_friendly_promotions/rules/option_value: Option Value(s) @@ -201,6 +206,8 @@ en: preferred_line_item_applicable: Should also apply to line items solidus_friendly_promotions/rules/line_item_option_value: description: Line Item has specified product with matching option value + solidus_friendly_promotions/rules/minimum_quantity: + description: Order contains minimum quantity of applicable items solidus_friendly_promotions/rules/product: description: Order includes specified product(s) line_item_level_description: 'Line item matches the specified products:' diff --git a/friendly_promotions/lib/generators/solidus_friendly_promotions/install/templates/initializer.rb b/friendly_promotions/lib/generators/solidus_friendly_promotions/install/templates/initializer.rb index 6b3fc756..a9232736 100644 --- a/friendly_promotions/lib/generators/solidus_friendly_promotions/install/templates/initializer.rb +++ b/friendly_promotions/lib/generators/solidus_friendly_promotions/install/templates/initializer.rb @@ -84,6 +84,7 @@ "SolidusFriendlyPromotions::Rules::FirstRepeatPurchaseSince", "SolidusFriendlyPromotions::Rules::ItemTotal", "SolidusFriendlyPromotions::Rules::DiscountedItemTotal", + "SolidusFriendlyPromotions::Rules::MinimumQuantity", "SolidusFriendlyPromotions::Rules::NthOrder", "SolidusFriendlyPromotions::Rules::OneUsePerUser", "SolidusFriendlyPromotions::Rules::OptionValue", diff --git a/friendly_promotions/spec/models/solidus_friendly_promotions/rules/minimum_quantity_spec.rb b/friendly_promotions/spec/models/solidus_friendly_promotions/rules/minimum_quantity_spec.rb new file mode 100644 index 00000000..ac1f0d70 --- /dev/null +++ b/friendly_promotions/spec/models/solidus_friendly_promotions/rules/minimum_quantity_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +RSpec.describe SolidusFriendlyPromotions::Rules::MinimumQuantity do + subject(:quantity_rule) { described_class.new(preferred_minimum_quantity: 2) } + + describe "#valid?" do + let(:promotion) { build(:friendly_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) do + create( + :order_with_line_items, + line_items_count: line_items.length, + line_items_attributes: line_items + ) + end + let(:promotion) { build(:friendly_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(:variant) } + let(:other_carry_on) { create(:variant) } + let(:everywhere_bag) { create(:product).master } + + let(:product_rule) { + SolidusFriendlyPromotions::Rules::LineItemProduct.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