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) }