diff --git a/promotions/app/models/concerns/solidus_promotions/conditions/taxon_condition.rb b/promotions/app/models/concerns/solidus_promotions/conditions/taxon_condition.rb
index e486dacb5e..9ea4035df2 100644
--- a/promotions/app/models/concerns/solidus_promotions/conditions/taxon_condition.rb
+++ b/promotions/app/models/concerns/solidus_promotions/conditions/taxon_condition.rb
@@ -52,6 +52,8 @@ def taxon_ids_with_children
end
def load_taxon_ids_with_children
+ return [] unless taxon_ids.present?
+
aggregation_function = if ActiveRecord::Base.connection.adapter_name.downcase.match?(/postgres/)
"string_agg(child.id::text, ',')"
else
diff --git a/promotions/app/models/solidus_promotions/conditions/taxon_revenue.rb b/promotions/app/models/solidus_promotions/conditions/taxon_revenue.rb
new file mode 100644
index 0000000000..64354169fe
--- /dev/null
+++ b/promotions/app/models/solidus_promotions/conditions/taxon_revenue.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module SolidusPromotions
+ module Conditions
+ class TaxonRevenue < Condition
+ include OrderLevelCondition
+ include TaxonCondition
+
+ preference :operator, :string, default: "gte"
+ preference :amount, :decimal, default: 0
+ preference :currency, :string, default: -> { Spree::Config.currency }
+
+ OPERATORS = {"gte" => :>=, "gt" => :>, "lt" => :<, "lte" => :<=}.freeze
+
+ def self.operator_options
+ OPERATORS.map do |name, _method|
+ [I18n.t(name, scope: [:solidus_promotions, :operators]), name]
+ end
+ end
+
+ def order_eligible?(order, _options = {})
+ matching = order.line_items.select do |line_item|
+ taxon_ids_with_children.any? do |taxon_and_descendant_ids|
+ (line_item.variant.product.classifications.map(&:taxon_id) & taxon_and_descendant_ids).any?
+ end
+ end
+ matching.sum(&:discounted_amount).public_send(OPERATORS.fetch(preferred_operator), preferred_amount)
+ end
+ end
+ end
+end
diff --git a/promotions/config/locales/en.yml b/promotions/config/locales/en.yml
index c4ada2b6ba..51c7eef671 100644
--- a/promotions/config/locales/en.yml
+++ b/promotions/config/locales/en.yml
@@ -175,6 +175,11 @@ en:
add: Add
destroy: Delete
update: Update
+ operators:
+ gt: greater than
+ gte: greater than or equal to
+ lt: less than
+ lte: less than or equal to
admin:
promotions:
benefits:
@@ -247,6 +252,7 @@ en:
solidus_promotions/conditions/price_product: Price matches specified product(s)
solidus_promotions/conditions/price_taxon: Price matches specified taxon(s)
solidus_promotions/conditions/price_option_value: Price matches specified option value(s)
+ solidus_promotions/conditions/taxon_revenue: Taxon Revenue
solidus_promotions/promotion_category:
one: Promotion Category
other: Promotion Categories
@@ -324,6 +330,10 @@ en:
description: Available only to logged in users
solidus_promotions/conditions/user_role:
description: Order includes User with specified Role(s)
+ solidus_promotions/conditions/taxon_revenue:
+ description: Revenue from configured taxons is...
+ preferred_operator: Operator
+ preferred_amount: Amount
solidus_promotions/calculators/tiered_flat_rate:
description: Flat Rate in tiers based on item amount
preferred_base_amount: Base Amount
diff --git a/promotions/lib/solidus_promotions/configuration.rb b/promotions/lib/solidus_promotions/configuration.rb
index ae36f82d1c..bd494109b2 100644
--- a/promotions/lib/solidus_promotions/configuration.rb
+++ b/promotions/lib/solidus_promotions/configuration.rb
@@ -91,7 +91,8 @@ def shipment_conditions=(conditions)
"SolidusPromotions::Conditions::ShippingMethod",
"SolidusPromotions::Conditions::PriceProduct",
"SolidusPromotions::Conditions::PriceTaxon",
- "SolidusPromotions::Conditions::PriceOptionValue"
+ "SolidusPromotions::Conditions::PriceOptionValue",
+ "SolidusPromotions::Conditions::TaxonRevenue"
]
add_class_set :benefits, default: [
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_taxon_revenue.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_taxon_revenue.html.erb
new file mode 100644
index 0000000000..a459648d95
--- /dev/null
+++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_taxon_revenue.html.erb
@@ -0,0 +1,28 @@
+
+ <%= condition.class.human_attribute_name(:description) %>
+
+<%= fields_for param_prefix, condition do |form| %>
+
+
+
+ <%= form.label :preferred_operator %>
+ <%= form.select :preferred_operator, options_for_select(condition.class.operator_options, condition.preferred_operator), {}, {class: 'custom-select select_item_total fullwidth'} %>
+
+
+
+
+
+ <%= fields_for param_prefix, condition do |f| %>
+ <%= f.label :preferred_amount %>
+ <%= render "solidus_promotions/admin/shared/number_with_currency", f: f, amount_attr: :preferred_amount, currency_attr: :preferred_currency %>
+ <% end %>
+
+
+
+
+
+ <%= form.label :taxon_ids_string, t('solidus_promotions.taxon_condition.choose_taxons') %>
+ <%= form.hidden_field :taxon_ids_string, value: condition.taxon_ids.join(","), is: "taxon-picker", class: "fullwidth" %>
+
+
+<% end %>
diff --git a/promotions/spec/models/solidus_promotions/conditions/taxon_revenue_spec.rb b/promotions/spec/models/solidus_promotions/conditions/taxon_revenue_spec.rb
new file mode 100644
index 0000000000..43a60d3a5d
--- /dev/null
+++ b/promotions/spec/models/solidus_promotions/conditions/taxon_revenue_spec.rb
@@ -0,0 +1,183 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe SolidusPromotions::Conditions::TaxonRevenue do
+ subject(:condition) { described_class.new(preferred_amount:, taxons: [matching_taxon]) }
+ let(:preferred_amount) { 30 }
+
+ let(:matching_product) { build(:product, taxons: [matching_taxon]) }
+ let(:non_matching_product) { build(:product, taxons: [other_taxon]) }
+ let(:matching_variant) { build(:variant, product: matching_product) }
+ let(:non_matching_variant) { build(:variant, product: non_matching_product) }
+
+ let(:matching_taxon) { create(:taxon) }
+ let(:other_taxon) { create(:taxon) }
+
+ let(:matching_item) { build(:line_item, variant: matching_variant, price: 30) }
+ let(:non_matching_item) { build(:line_item, variant: non_matching_variant, price: 50) }
+ let(:order) { build(:order, line_items: [matching_item, non_matching_item]) }
+
+ describe "preferences" do
+ subject(:condition) { described_class.new }
+ it "defaults preferred_operator to 'gte'" do
+ expect(condition.preferred_operator).to eq("gte")
+ end
+
+ it "defaults preferred_amount to 0" do
+ expect(condition.preferred_amount).to eq(0)
+ end
+
+ it "accepts 'gt' as a valid operator" do
+ condition.preferred_operator = "gt"
+ expect(condition.preferred_operator).to eq("gt")
+ end
+ end
+
+ describe "taxons association" do
+ it "can have multiple taxons" do
+ condition.taxons << other_taxon
+ expect(condition.taxons).to include(matching_taxon, other_taxon)
+ end
+ end
+
+ describe ".operator_options" do
+ subject(:operator_options) { described_class.operator_options }
+
+ it {
+ is_expected.to contain_exactly(
+ ["greater than or equal to", "gte"],
+ ["greater than", "gt"],
+ ["less than", "lt"],
+ ["less than or equal to", "lte"]
+ )
+ }
+ end
+
+ describe "#order_eligible?" do
+ context "with operator 'gte' (default)" do
+ context "when the taxon revenue equals the threshold" do
+ it "is eligible" do
+ # matching_item.discounted_amount == 30, non_matching_item is ignored
+ expect(condition).to be_order_eligible(order)
+ end
+ end
+
+ context "when the taxon revenue exceeds the threshold" do
+ let(:matching_item) { build(:line_item, variant: matching_variant, price: 50) }
+
+ it "is eligible" do
+ expect(condition).to be_order_eligible(order)
+ end
+ end
+
+ context "when the taxon revenue is below the threshold" do
+ let(:matching_item) { build(:line_item, variant: matching_variant, price: 10) }
+
+ it "is not eligible" do
+ expect(condition).not_to be_order_eligible(order)
+ end
+ end
+ end
+
+ context "with operator 'gt' (strictly greater than)" do
+ before do
+ condition.preferred_operator = "gt"
+ end
+
+ context "when the taxon revenue equals the threshold exactly" do
+ it "is not eligible" do
+ # matching_item.discounted_amount == 30
+ expect(condition).not_to be_order_eligible(order)
+ end
+ end
+
+ context "when the taxon revenue exceeds the threshold" do
+ let(:matching_item) { build(:line_item, variant: matching_variant, price: 31) }
+
+ it "is eligible" do
+ expect(condition).to be_order_eligible(order)
+ end
+ end
+ end
+
+ context "when no line items belong to the configured taxons" do
+ let(:order) { build(:order, line_items: [non_matching_item]) }
+ let(:preferred_amount) { 10 }
+
+ it "is not eligible because the taxon revenue is zero" do
+ expect(condition).not_to be_order_eligible(order)
+ end
+ end
+
+ context "when the order has no line items" do
+ let(:order) { build_stubbed(:order, line_items: []) }
+ it "is not eligible" do
+ expect(condition).not_to be_order_eligible(order)
+ end
+
+ context "when the preferred amount is zero" do
+ let(:preferred_amount) { 0 }
+
+ it { is_expected.to be_order_eligible(order) }
+ end
+ end
+
+ context "when multiple taxons are configured" do
+ let(:preferred_amount) { 50 }
+ let(:other_matching_item) { build(:line_item, price: 25, product: non_matching_product) }
+ let(:order) { build_stubbed(:order, line_items: [matching_item, other_matching_item, non_matching_item]) }
+
+ before do
+ condition.taxons << other_taxon
+ end
+ it "sums discounted amounts from all matching taxons" do
+ # 30 (taxon) + 25 (other_taxon) = 55 >= 50
+ expect(condition).to be_order_eligible(order)
+ end
+ end
+
+ context "when a line item belongs to multiple taxons" do
+ let(:multi_taxon_item) { build(:line_item, price: 30, product: multi_taxon_product) }
+ let(:multi_taxon_product) { build(:product, taxons: [matching_taxon, other_taxon]) }
+ let(:order) { build_stubbed(:order, line_items: [multi_taxon_item]) }
+ let(:preferred_amount) { 35 }
+
+ it "counts the item once (no double-counting)" do
+ expect(condition).not_to be_order_eligible(order)
+ end
+
+ context "and all are eligible" do
+ before do
+ condition.taxons << other_taxon
+ end
+
+ it "still counts the item only once" do
+ # 40, not 80
+ expect(condition).not_to be_order_eligible(order)
+ end
+ end
+ end
+
+ context "when the discounted_amount reflects applied promotions" do
+ before { condition.preferred_amount = 20 }
+
+ let(:order) { build_stubbed(:order, line_items: [matching_item]) }
+
+ before do
+ allow(matching_item).to receive(:discounted_amount).and_return(15)
+ end
+ it "uses the discounted amount, not the original price" do
+ expect(condition).not_to be_order_eligible(order)
+ end
+ end
+
+ context "when no taxons are configured on the condition" do
+ subject(:condition) { described_class.new(preferred_amount: 1) }
+
+ it "does not count any line items and is not eligible above zero" do
+ expect(condition).not_to be_order_eligible(order)
+ end
+ end
+ end
+end