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