Skip to content

Commit 08825b5

Browse files
committed
TaxonRevenue condition: Add "exclude" match policy
It's useful to only count revenue from certain taxons, but some promotions require counting all revenue except that from the selected taxons. This commit extends the `TaxonRevenue` condition such that one can exclude taxons from revenue. An example promotions that can be done with this condition that can be done with this condition would be: A free shirt for all non-belt purchases.
1 parent d4463ad commit 08825b5

3 files changed

Lines changed: 147 additions & 5 deletions

File tree

promotions/app/models/solidus_promotions/conditions/taxon_revenue.rb

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,42 @@ class TaxonRevenue < Condition
99
preference :operator, :string, default: "gte"
1010
preference :amount, :decimal, default: 0
1111
preference :currency, :string, default: -> { Spree::Config.currency }
12+
preference :match_policy, :string, default: "include"
1213

1314
OPERATORS = {"gte" => :>=, "gt" => :>, "lt" => :<, "lte" => :<=}.freeze
15+
MATCH_POLICIES = ["include", "exclude"].freeze
16+
17+
validates :preferred_match_policy, inclusion: { in: MATCH_POLICIES }
1418

1519
def self.operator_options
1620
OPERATORS.map do |name, _method|
1721
[I18n.t(name, scope: [:solidus_promotions, :operators]), name]
1822
end
1923
end
2024

21-
def order_eligible?(order, _options = {})
22-
matching = order.line_items.select do |line_item|
23-
taxon_ids_with_children.any? do |taxon_and_descendant_ids|
24-
(line_item.variant.product.classifications.map(&:taxon_id) & taxon_and_descendant_ids).any?
25-
end
25+
def self.match_policy_options
26+
MATCH_POLICIES.map do |name|
27+
[I18n.t(name, scope: %i[solidus_promotions conditions taxon_revenue match_policies]), name]
2628
end
29+
end
30+
31+
def order_eligible?(order, _options = {})
32+
matching = order.line_items.select { |line_item| taxon_match?(line_item) }
33+
2734
matching.sum(&:discounted_amount).public_send(OPERATORS.fetch(preferred_operator), preferred_amount)
2835
end
36+
37+
private
38+
39+
def taxon_match?(line_item)
40+
line_item_taxon_ids = line_item.variant.product.classifications.map(&:taxon_id)
41+
42+
in_selected_taxons = taxon_ids_with_children.any? do |taxon_and_descendant_ids|
43+
(line_item_taxon_ids & taxon_and_descendant_ids).any?
44+
end
45+
46+
(preferred_match_policy == "exclude") ? !in_selected_taxons : in_selected_taxons
47+
end
2948
end
3049
end
3150
end

promotions/config/locales/en.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ en:
7676
match_policies:
7777
include: Line item's product has one of the chosen taxons
7878
exclude: Line item's product does not have one of the chosen taxons
79+
taxon_revenue:
80+
match_policies:
81+
include: Revenue from selected taxons
82+
exclude: Revenue excluding selected taxons
7983
item_total_condition:
8084
operators:
8185
gt: greater than

promotions/spec/models/solidus_promotions/conditions/taxon_revenue_spec.rb

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@
3232
condition.preferred_operator = "gt"
3333
expect(condition.preferred_operator).to eq("gt")
3434
end
35+
36+
it "defaults preferred_match_policy to 'include'" do
37+
expect(condition.preferred_match_policy).to eq("include")
38+
end
39+
40+
it "accepts 'exclude' as a valid match policy" do
41+
condition.preferred_match_policy = "exclude"
42+
expect(condition.preferred_match_policy).to eq("exclude")
43+
end
44+
45+
it "is invalid with an unrecognized match policy" do
46+
condition.preferred_match_policy = "some_other_policy"
47+
expect(condition).not_to be_valid
48+
expect(condition.errors[:preferred_match_policy]).to be_present
49+
end
3550
end
3651

3752
describe "taxons association" do
@@ -54,6 +69,17 @@
5469
}
5570
end
5671

72+
describe ".match_policy_options" do
73+
subject(:match_policy_options) { described_class.match_policy_options }
74+
75+
it {
76+
is_expected.to contain_exactly(
77+
["Revenue from selected taxons", "include"],
78+
["Revenue excluding selected taxons", "exclude"]
79+
)
80+
}
81+
end
82+
5783
describe "#order_eligible?" do
5884
context "with operator 'gte' (default)" do
5985
context "when the taxon revenue equals the threshold" do
@@ -179,5 +205,98 @@
179205
expect(condition).not_to be_order_eligible(order)
180206
end
181207
end
208+
209+
context "when preferred_match_policy is 'exclude'" do
210+
before { condition.preferred_match_policy = "exclude" }
211+
212+
context "when the non-excluded-taxon revenue equals the threshold" do
213+
let(:preferred_amount) { 50 }
214+
215+
it "is eligible" do
216+
# matching_item (in matching_taxon) is excluded from the sum;
217+
# only non_matching_item (50) counts, and 50 >= 50
218+
expect(condition).to be_order_eligible(order)
219+
end
220+
end
221+
222+
context "when the non-excluded-taxon revenue exceeds the threshold" do
223+
let(:preferred_amount) { 40 }
224+
225+
it "is eligible" do
226+
expect(condition).to be_order_eligible(order)
227+
end
228+
end
229+
230+
context "when the non-excluded-taxon revenue is below the threshold" do
231+
let(:preferred_amount) { 60 }
232+
233+
it "is not eligible" do
234+
# only non_matching_item (50) counts, 50 < 60
235+
expect(condition).not_to be_order_eligible(order)
236+
end
237+
end
238+
239+
it "does not count revenue from line items in the excluded taxon" do
240+
# If the excluded item were counted, total would be 80 (30 + 50).
241+
# With it excluded, only 50 counts, so a threshold of 51 is not met.
242+
condition.preferred_amount = 51
243+
expect(condition).not_to be_order_eligible(order)
244+
end
245+
246+
context "when all line items belong to the excluded taxon" do
247+
let(:order) { build_stubbed(:order, line_items: [matching_item]) }
248+
let(:preferred_amount) { 1 }
249+
250+
it "is not eligible because the non-excluded revenue is zero" do
251+
expect(condition).not_to be_order_eligible(order)
252+
end
253+
254+
context "when the preferred amount is zero" do
255+
let(:preferred_amount) { 0 }
256+
257+
it { is_expected.to be_order_eligible(order) }
258+
end
259+
end
260+
261+
context "when multiple taxons are excluded" do
262+
let(:other_excluded_item) { build(:line_item, price: 25, product: non_matching_product) }
263+
let(:third_taxon) { create(:taxon) }
264+
let(:third_product) { build(:product, taxons: [third_taxon]) }
265+
let(:remaining_item) { build(:line_item, price: 15, product: third_product) }
266+
let(:order) { build_stubbed(:order, line_items: [matching_item, other_excluded_item, remaining_item]) }
267+
let(:preferred_amount) { 15 }
268+
269+
before do
270+
condition.taxons << other_taxon
271+
end
272+
273+
it "only counts revenue from line items outside every excluded taxon" do
274+
# matching_item (matching_taxon) and other_excluded_item (other_taxon) are excluded;
275+
# only remaining_item (15) counts, and 15 >= 15
276+
expect(condition).to be_order_eligible(order)
277+
end
278+
end
279+
280+
context "when a line item belongs to both an excluded and a non-excluded taxon" do
281+
let(:multi_taxon_item) { build(:line_item, price: 30, product: multi_taxon_product) }
282+
let(:multi_taxon_product) { build(:product, taxons: [matching_taxon, other_taxon]) }
283+
let(:order) { build_stubbed(:order, line_items: [multi_taxon_item]) }
284+
let(:preferred_amount) { 1 }
285+
286+
it "is excluded, since it belongs to the excluded taxon" do
287+
expect(condition).not_to be_order_eligible(order)
288+
end
289+
end
290+
291+
context "when no taxons are configured on the condition" do
292+
subject(:condition) { described_class.new(preferred_amount:, preferred_match_policy: "exclude") }
293+
let(:preferred_amount) { 79 }
294+
295+
it "counts all line items, since none belong to an excluded taxon" do
296+
# 30 + 50 = 80 >= 79
297+
expect(condition).to be_order_eligible(order)
298+
end
299+
end
300+
end
182301
end
183302
end

0 commit comments

Comments
 (0)