diff --git a/backend/app/assets/stylesheets/spree/backend/sections/_promotions.scss b/backend/app/assets/stylesheets/spree/backend/sections/_promotions.scss
index 687e5e19f51..5ffb5bccfa4 100644
--- a/backend/app/assets/stylesheets/spree/backend/sections/_promotions.scss
+++ b/backend/app/assets/stylesheets/spree/backend/sections/_promotions.scss
@@ -12,12 +12,6 @@
}
}
-#actions_container {
- .edit_promotion {
- margin-top: 73px;
- }
-}
-
.promotion-block {
padding: 0 1.25rem 0.5rem;
background-color: lighten($color-border, 5);
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