Skip to content

Commit

Permalink
Add MinimumQuantity promotion rule
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
adammathys and benjaminwil committed Nov 1, 2023
1 parent 0a9f663 commit cb4692e
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div class="field">
<% 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 %>
</div>
59 changes: 59 additions & 0 deletions core/app/models/spree/promotion/rules/minimum_quantity.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions core/config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: '
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions core/lib/spree/app_configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 110 additions & 0 deletions core/spec/models/spree/promotion/rules/minimum_quantity_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit cb4692e

Please sign in to comment.