diff --git a/promotions/app/models/concerns/solidus_promotions/discountable_amount.rb b/promotions/app/models/concerns/solidus_promotions/discountable_amount.rb
index f40d86c2628..3d6dbe4e9b6 100644
--- a/promotions/app/models/concerns/solidus_promotions/discountable_amount.rb
+++ b/promotions/app/models/concerns/solidus_promotions/discountable_amount.rb
@@ -14,6 +14,14 @@ def current_discounts=(args)
@current_discounts = args
end
+ def current_lane_discounts
+ @current_lane_discounts ||= []
+ end
+
+ def current_lane_discounts=(args)
+ @current_lane_discounts = args
+ end
+
def reset_current_discounts
@current_discounts = []
end
diff --git a/promotions/app/models/solidus_promotions/benefit.rb b/promotions/app/models/solidus_promotions/benefit.rb
index 61fab35b631..48b5639aeac 100644
--- a/promotions/app/models/solidus_promotions/benefit.rb
+++ b/promotions/app/models/solidus_promotions/benefit.rb
@@ -128,7 +128,7 @@ def discount(adjustable, ...)
# @return [BigDecimal] a negative amount suitable for creating an adjustment
def compute_amount(adjustable, ...)
promotion_amount = calculator.compute(adjustable, ...) || Spree::ZERO
- [adjustable.discountable_amount, promotion_amount.abs].min * -1
+ [remaining_discountable_amount_for_item(adjustable), promotion_amount.abs].min * -1
end
# Builds the localized label for adjustments created by this benefit.
@@ -231,6 +231,23 @@ def possible_conditions
private
+ def remaining_discountable_amount_for_item(item)
+ return item.discountable_amount unless promotion.amount_limit
+ remaining_amount_limit = promotion.amount_limit + promotion_total_for_order(item.order)
+ return Spree::ZERO if remaining_amount_limit <= Spree::ZERO
+ [item.discountable_amount, remaining_amount_limit].min
+ end
+
+ def promotion_total_for_order(order)
+ current_lane_discounts_for_order(order).sum(&:amount)
+ end
+
+ def current_lane_discounts_for_order(order)
+ (order.line_items + order.shipments).flat_map(&:current_lane_discounts).select do |item_discount|
+ item_discount.source.in?(promotion.benefits)
+ end
+ end
+
# Prevents destroying a benefit when it has adjustments on completed orders.
#
# Adds an error and aborts the destroy callback chain when such adjustments exist.
diff --git a/promotions/app/models/solidus_promotions/order_adjuster/discount_order.rb b/promotions/app/models/solidus_promotions/order_adjuster/discount_order.rb
index 3811692b2fa..711e7828403 100644
--- a/promotions/app/models/solidus_promotions/order_adjuster/discount_order.rb
+++ b/promotions/app/models/solidus_promotions/order_adjuster/discount_order.rb
@@ -18,11 +18,12 @@ def call
lane_promotions = promotions.select { |promotion| promotion.lane == lane }
lane_benefits = eligible_benefits_for_promotable(lane_promotions.flat_map(&:benefits), order)
perform_order_benefits(lane_benefits, lane) unless dry_run
- line_item_discounts = adjust_line_items(lane_benefits)
- shipment_discounts = adjust_shipments(lane_benefits)
- shipping_rate_discounts = adjust_shipping_rates(lane_benefits)
- (line_item_discounts + shipment_discounts + shipping_rate_discounts).each do |item, chosen_discounts|
- item.current_discounts.concat(chosen_discounts)
+ discounted_line_items = adjust_line_items(lane_benefits)
+ discounted_shipments = adjust_shipments(lane_benefits)
+ discounted_shipping_rates = adjust_shipping_rates(lane_benefits)
+ (discounted_line_items + discounted_shipments + discounted_shipping_rates).each do |discountable|
+ discountable.current_discounts.concat(discountable.current_lane_discounts)
+ discountable.current_lane_discounts.clear
end
end
@@ -50,7 +51,8 @@ def adjust_line_items(benefits)
discounts = generate_discounts(benefits, line_item)
chosen_item_discounts = SolidusPromotions.config.discount_chooser_class.new(discounts).call
- [line_item, chosen_item_discounts]
+ line_item.current_lane_discounts += chosen_item_discounts
+ line_item
end
end
@@ -58,7 +60,8 @@ def adjust_shipments(benefits)
order.shipments.map do |shipment|
discounts = generate_discounts(benefits, shipment)
chosen_item_discounts = SolidusPromotions.config.discount_chooser_class.new(discounts).call
- [shipment, chosen_item_discounts]
+ shipment.current_lane_discounts += chosen_item_discounts
+ shipment
end
end
@@ -68,7 +71,8 @@ def adjust_shipping_rates(benefits)
discounts = generate_discounts(benefits, rate)
chosen_item_discounts = SolidusPromotions.config.discount_chooser_class.new(discounts).call
- [rate, chosen_item_discounts]
+ rate.current_lane_discounts += chosen_item_discounts
+ rate
end
end
diff --git a/promotions/db/migrate/20251113165947_add_amount_limit_to_promotions.rb b/promotions/db/migrate/20251113165947_add_amount_limit_to_promotions.rb
new file mode 100644
index 00000000000..47db76a105e
--- /dev/null
+++ b/promotions/db/migrate/20251113165947_add_amount_limit_to_promotions.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddAmountLimitToPromotions < ActiveRecord::Migration[7.0]
+ def change
+ add_column :solidus_promotions_promotions, :amount_limit, :decimal, precision: 10, scale: 2, null: true
+ end
+end
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotions/_form.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotions/_form.html.erb
index a752bd8ede6..e3231bf2887 100644
--- a/promotions/lib/views/backend/solidus_promotions/admin/promotions/_form.html.erb
+++ b/promotions/lib/views/backend/solidus_promotions/admin/promotions/_form.html.erb
@@ -49,6 +49,11 @@
<% end %>
<% end %>
+ <%= f.field_container :overall_amount_limit do %>
+ <%= f.label :amount_limit %>
+ <%= f.number_field :amount_limit, class: 'fullwidth' %>
+ <% end %>
+
<%= f.label :starts_at %>
<%= f.field_hint :starts_at %>
diff --git a/promotions/spec/models/promotion/promotion_with_amount_limit_spec.rb b/promotions/spec/models/promotion/promotion_with_amount_limit_spec.rb
new file mode 100644
index 00000000000..c7caa722e2e
--- /dev/null
+++ b/promotions/spec/models/promotion/promotion_with_amount_limit_spec.rb
@@ -0,0 +1,195 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe SolidusPromotions::Calculators::Percent, type: :model do
+ describe "#compute_line_item" do
+ let(:promotion) { create(:solidus_promotion, amount_limit:, apply_automatically: true) }
+ let(:order) { create(:order) }
+
+ let!(:taxon_electronics) { create(:taxon, name: "Electronics") }
+ let!(:taxon_clothing) { create(:taxon, name: "Clothing") }
+
+ let!(:product_laptop) { create(:product, price: 1000, taxons: [taxon_electronics]) }
+ let!(:product_phone) { create(:product, price: 800, taxons: [taxon_electronics]) }
+ let!(:product_shirt) { create(:product, price: 50, taxons: [taxon_clothing]) }
+ let!(:product_jacket) { create(:product, price: 200, taxons: [taxon_clothing]) }
+
+ let(:calculator_electronics) { described_class.new(preferred_percent: 20) }
+ let(:calculator_clothing) { described_class.new(preferred_percent: 30) }
+
+ let(:amount_limit) { 250 }
+ let!(:benefit_electronics) do
+ SolidusPromotions::Benefits::AdjustLineItem.create!(
+ calculator: calculator_electronics,
+ promotion: promotion,
+ conditions: [SolidusPromotions::Conditions::LineItemTaxon.new(taxons: [taxon_electronics])]
+ )
+ end
+
+ let!(:benefit_clothing) do
+ SolidusPromotions::Benefits::AdjustLineItem.create!(
+ calculator: calculator_clothing,
+ promotion: promotion,
+ conditions: [SolidusPromotions::Conditions::LineItemTaxon.new(taxons: [taxon_clothing])]
+ )
+ end
+
+ context "when both benefits stay within cap" do
+ before do
+ order.contents.add(product_laptop.master, 1)
+ order.contents.add(product_shirt.master, 1)
+ end
+
+ it "applies full discounts" do
+ expect(order.promo_total).to eq(-215)
+ expect(order.line_items.find_by(variant: product_laptop.master).adjustment_total).to eq(-200)
+ expect(order.line_items.find_by(variant: product_shirt.master).adjustment_total).to eq(-15)
+ end
+ end
+
+ context "when electronics benefit exhausts the cap" do
+ before do
+ order.contents.add(product_laptop.master, 1)
+ order.contents.add(product_phone.master, 1)
+ order.contents.add(product_shirt.master, 1)
+ end
+
+ it "caps electronics at $250 and gives clothing $0" do
+ expect(order.promo_total).to eq(-250)
+ laptop_discount = order.line_items.find_by(variant: product_laptop.master).adjustment_total
+ phone_discount = order.line_items.find_by(variant: product_phone.master).adjustment_total
+ expect(laptop_discount + phone_discount).to eq(-250)
+ expect(order.line_items.find_by(variant: product_shirt.master).adjustment_total).to eq(0)
+ end
+ end
+
+ context "when clothing benefit is applied first" do
+ before do
+ order.contents.add(product_shirt.master, 1)
+ order.contents.add(product_jacket.master, 1)
+ order.contents.add(product_phone.master, 1)
+ end
+
+ it "applies clothing discounts then remaining cap to electronics" do
+ shirt_discount = order.line_items.find_by(variant: product_shirt.master).adjustment_total
+ jacket_discount = order.line_items.find_by(variant: product_jacket.master).adjustment_total
+ phone_discount = order.line_items.find_by(variant: product_phone.master).adjustment_total
+
+ expect(shirt_discount).to eq(-15)
+ expect(jacket_discount).to eq(-60)
+ expect(phone_discount).to eq(-160)
+ expect(order.promo_total).to eq(-235)
+ end
+ end
+
+ context "when cap is exactly reached" do
+ before do
+ order.contents.add(product_laptop.master, 1)
+ order.contents.add(product_shirt.master, 1)
+ order.contents.add(product_jacket.master, 1)
+ end
+
+ it "stops at exactly $250" do
+ expect(order.promo_total).to eq(-250)
+ end
+ end
+
+ context "when cap is exceeded by both benefits combined" do
+ before do
+ order.contents.add(product_laptop.master, 2)
+ order.contents.add(product_jacket.master, 2)
+ end
+
+ it "distributes cap proportionally" do
+ expect(order.promo_total).to eq(-250)
+ end
+ end
+
+ context "when one line item alone would exceed cap" do
+ let!(:expensive_laptop) { create(:product, price: 2000, taxons: [taxon_electronics]) }
+
+ before do
+ order.contents.add(expensive_laptop.master, 1)
+ end
+
+ it "caps single item discount at $250" do
+ expect(order.promo_total).to eq(-250)
+ end
+ end
+
+ context "with small purchases" do
+ before do
+ order.contents.add(product_shirt.master, 2)
+ end
+
+ it "applies full discount when well below cap" do
+ expect(order.promo_total).to eq(-30)
+ end
+ end
+
+ context "when cap is zero" do
+ let(:amount_limit) { 0 }
+
+ before do
+ order.contents.add(product_laptop.master, 1)
+ end
+
+ it "applies no discount" do
+ expect(order.promo_total).to eq(0)
+ end
+ end
+
+ context "when cap is negative" do
+ let(:amount_limit) { -100 }
+
+ before do
+ order.contents.add(product_laptop.master, 1)
+ end
+
+ it "applies no discount" do
+ expect(order.promo_total).to eq(0)
+ end
+ end
+
+ context "when order has no applicable items" do
+ let!(:product_other) { create(:product, price: 100) }
+
+ before do
+ order.contents.add(product_other.master, 1)
+ end
+
+ it "applies no discount" do
+ expect(order.promo_total).to eq(0)
+ end
+ end
+
+ context "with multiple quantities of same product" do
+ before do
+ order.contents.add(product_shirt.master, 5)
+ order.contents.add(product_jacket.master, 2)
+ end
+
+ it "calculates correctly across quantities" do
+ total_clothing = (50 * 5) + (200 * 2)
+ expected_discount = total_clothing * 0.30
+ expect(order.promo_total).to eq(-expected_discount)
+ end
+ end
+
+ context "when benefits have different percentages approaching cap" do
+ before do
+ order.contents.add(product_phone.master, 1)
+ order.contents.add(product_jacket.master, 3)
+ end
+
+ it "applies both benefits up to shared cap" do
+ phone_full = 800 * 0.20
+ jacket_full = 200 * 3 * 0.30
+ total_full = phone_full + jacket_full
+
+ expect(order.promo_total).to eq(-[total_full, 250].min)
+ end
+ end
+ end
+end
diff --git a/promotions/spec/models/solidus_promotions/promotion_spec.rb b/promotions/spec/models/solidus_promotions/promotion_spec.rb
index 67876927853..621b394e8c8 100644
--- a/promotions/spec/models/solidus_promotions/promotion_spec.rb
+++ b/promotions/spec/models/solidus_promotions/promotion_spec.rb
@@ -11,6 +11,8 @@
it { is_expected.to have_many(:order_promotions).dependent(:destroy) }
it { is_expected.to have_many(:code_batches).dependent(:destroy) }
+ it { is_expected.to respond_to(:amount_limit) }
+
describe "lane" do
it { is_expected.to respond_to(:lane) }