From 38d553ff822a99461d081d81de01fcba3dd50e46 Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Wed, 3 Jun 2026 08:41:32 +0200 Subject: [PATCH 1/2] Add TaxonRevenue condition This condition adds a promotion condition that allows store owners to trigger a benefit if the revenue from a defined set of taxons exceeds some value. For example, with this condition we can do things like: If you spend 150 USD on pants, you get a free belt. --- .../conditions/taxon_revenue.rb | 31 +++ promotions/config/locales/en.yml | 10 + .../lib/solidus_promotions/configuration.rb | 3 +- .../condition_fields/_taxon_revenue.html.erb | 28 +++ .../conditions/taxon_revenue_spec.rb | 183 ++++++++++++++++++ 5 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 promotions/app/models/solidus_promotions/conditions/taxon_revenue.rb create mode 100644 promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_taxon_revenue.html.erb create mode 100644 promotions/spec/models/solidus_promotions/conditions/taxon_revenue_spec.rb 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 From 2c05bebbcd259d7a4e50698c7632c453ff701368 Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Wed, 3 Jun 2026 09:52:22 +0200 Subject: [PATCH 2/2] Bugfix: Do not interpolate empty array into SQL This fixes an edge case, a taxon condition without any taxons defined. Prior to this commit, that would error on MySQL and Postgres. --- .../concerns/solidus_promotions/conditions/taxon_condition.rb | 2 ++ 1 file changed, 2 insertions(+) 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