From ed049d0da452a5a0a4ddcb423eef45be2f4f00ab Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Tue, 10 Oct 2023 15:33:10 +0200 Subject: [PATCH 1/2] Add new order-level rule: Discounted Item Total This rule applies depending on the discountable item total of an order after promotions from previous lanes have applied. --- .../order_decorator.rb | 4 + .../rules/discounted_item_total.rb | 22 +++ config/locales/en.yml | 1 + .../install/templates/initializer.rb | 1 + .../rules/discounted_item_total_spec.rb | 131 ++++++++++++++++++ 5 files changed, 159 insertions(+) create mode 100644 app/models/solidus_friendly_promotions/rules/discounted_item_total.rb create mode 100644 spec/models/solidus_friendly_promotions/rules/discounted_item_total_spec.rb diff --git a/app/decorators/models/solidus_friendly_promotions/order_decorator.rb b/app/decorators/models/solidus_friendly_promotions/order_decorator.rb index f6130def..c15d89dd 100644 --- a/app/decorators/models/solidus_friendly_promotions/order_decorator.rb +++ b/app/decorators/models/solidus_friendly_promotions/order_decorator.rb @@ -21,6 +21,10 @@ def ensure_promotions_eligible super end + def discountable_item_total + line_items.sum(&:discountable_amount) + end + def reset_current_discounts line_items.each(&:reset_current_discounts) shipments.each(&:reset_current_discounts) diff --git a/app/models/solidus_friendly_promotions/rules/discounted_item_total.rb b/app/models/solidus_friendly_promotions/rules/discounted_item_total.rb new file mode 100644 index 00000000..32f1886b --- /dev/null +++ b/app/models/solidus_friendly_promotions/rules/discounted_item_total.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module SolidusFriendlyPromotions + module Rules + # A rule to apply to an order greater than (or greater than or equal to) + # a specific amount after previous promotions have applied + # + # To add extra operators please override `self.operators_map` or any other helper method. + # To customize the error message you can also override `ineligible_message`. + class DiscountedItemTotal < ItemTotal + def to_partial_path + "solidus_friendly_promotions/admin/promotion_rules/rules/item_total" + end + + private + + def total_for_order(order) + order.discountable_item_total + end + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index d3215351..d6f222dc 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -113,6 +113,7 @@ en: solidus_friendly_promotions/rules/first_order: First Order solidus_friendly_promotions/rules/first_repeat_purchase_since: First Repeat Purchase Since solidus_friendly_promotions/rules/item_total: Item Total + solidus_friendly_promotions/rules/discounted_item_total: Item Total after previous lanes solidus_friendly_promotions/rules/landing_page: Landing Page solidus_friendly_promotions/rules/nth_order: Nth Order solidus_friendly_promotions/rules/one_use_per_user: One Use Per User diff --git a/lib/generators/solidus_friendly_promotions/install/templates/initializer.rb b/lib/generators/solidus_friendly_promotions/install/templates/initializer.rb index 22c2e4ef..87da66a8 100644 --- a/lib/generators/solidus_friendly_promotions/install/templates/initializer.rb +++ b/lib/generators/solidus_friendly_promotions/install/templates/initializer.rb @@ -83,6 +83,7 @@ "SolidusFriendlyPromotions::Rules::FirstOrder", "SolidusFriendlyPromotions::Rules::FirstRepeatPurchaseSince", "SolidusFriendlyPromotions::Rules::ItemTotal", + "SolidusFriendlyPromotions::Rules::DiscountedItemTotal", "SolidusFriendlyPromotions::Rules::NthOrder", "SolidusFriendlyPromotions::Rules::OneUsePerUser", "SolidusFriendlyPromotions::Rules::OptionValue", diff --git a/spec/models/solidus_friendly_promotions/rules/discounted_item_total_spec.rb b/spec/models/solidus_friendly_promotions/rules/discounted_item_total_spec.rb new file mode 100644 index 00000000..59af5183 --- /dev/null +++ b/spec/models/solidus_friendly_promotions/rules/discounted_item_total_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SolidusFriendlyPromotions::Rules::DiscountedItemTotal, type: :model do + let(:rule) do + described_class.new( + preferred_amount: preferred_amount, + preferred_operator: preferred_operator + ) + end + let(:order) { instance_double("Spree::Order", discountable_item_total: item_total, currency: order_currency) } + let(:preferred_amount) { 50 } + let(:order_currency) { "USD" } + + context "preferred operator set to gt" do + let(:preferred_operator) { "gt" } + + context "item total is greater than preferred amount" do + let(:item_total) { 51 } + + it "is eligible when item total is greater than preferred amount" do + expect(rule).to be_eligible(order) + end + + context "when the order is a different currency" do + let(:order_currency) { "CAD" } + + it "is not eligible" do + expect(rule).not_to be_eligible(order) + end + end + end + + context "when item total is equal to preferred amount" do + let(:item_total) { 50 } + + it "is not eligible" do + expect(rule).not_to be_eligible(order) + end + + it "set an error message" do + rule.eligible?(order) + expect(rule.eligibility_errors.full_messages.first) + .to eq "This coupon code can't be applied to orders less than or equal to $50.00." + end + + it "sets an error code" do + rule.eligible?(order) + expect(rule.eligibility_errors.details[:base].first[:error_code]) + .to eq :item_total_less_than_or_equal + end + end + + context "when item total is lower than preferred amount" do + let(:item_total) { 49 } + + it "is not eligible" do + expect(rule).not_to be_eligible(order) + end + + it "set an error message" do + rule.eligible?(order) + expect(rule.eligibility_errors.full_messages.first) + .to eq "This coupon code can't be applied to orders less than or equal to $50.00." + end + + it "sets an error code" do + rule.eligible?(order) + expect(rule.eligibility_errors.details[:base].first[:error_code]) + .to eq :item_total_less_than_or_equal + end + end + end + + context "preferred operator set to gte" do + let(:preferred_operator) { "gte" } + + context "total is greater than preferred amount" do + let(:item_total) { 51 } + + it "is eligible when item total is greater than preferred amount" do + expect(rule).to be_eligible(order) + end + + context "when the order is a different currency" do + let(:order_currency) { "CAD" } + + it "is not eligible" do + expect(rule).not_to be_eligible(order) + end + end + end + + context "item total is equal to preferred amount" do + let(:item_total) { 50 } + + it "is eligible" do + expect(rule).to be_eligible(order) + end + + context "when the order is a different currency" do + let(:order_currency) { "CAD" } + + it "is not eligible" do + expect(rule).not_to be_eligible(order) + end + end + end + + context "when item total is lower than preferred amount" do + let(:item_total) { 49 } + + it "is not eligible" do + expect(rule).not_to be_eligible(order) + end + + it "set an error message" do + rule.eligible?(order) + expect(rule.eligibility_errors.full_messages.first) + .to eq "This coupon code can't be applied to orders less than $50.00." + end + + it "sets an error code" do + rule.eligible?(order) + expect(rule.eligibility_errors.details[:base].first[:error_code]) + .to eq :item_total_less_than + end + end + end +end From 8b9ad8e02a6f0f4912ef223cec049330d4908a15 Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Tue, 10 Oct 2023 15:08:50 +0200 Subject: [PATCH 2/2] Apply order-level rules by lane If we don't do this, promotions with order-level rules that depend on intermediate amounts don't work as expected. --- .../friendly_promotion_discounter.rb | 8 +++++--- spec/models/promotion/integration_spec.rb | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app/models/solidus_friendly_promotions/friendly_promotion_discounter.rb b/app/models/solidus_friendly_promotions/friendly_promotion_discounter.rb index 05e4d397..ce378650 100644 --- a/app/models/solidus_friendly_promotions/friendly_promotion_discounter.rb +++ b/app/models/solidus_friendly_promotions/friendly_promotion_discounter.rb @@ -6,8 +6,7 @@ class FriendlyPromotionDiscounter def initialize(order) @order = order - possible_promotions = PromotionLoader.new(order: order).call - @promotions = PromotionEligibility.new(promotable: order, possible_promotions: possible_promotions).call + @promotions = PromotionLoader.new(order: order).call end def call @@ -16,7 +15,10 @@ def call order.reset_current_discounts SolidusFriendlyPromotions::Promotion.ordered_lanes.each do |lane, _index| - lane_promotions = promotions.select { |promotion| promotion.lane == lane } + lane_promotions = PromotionEligibility.new( + promotable: order, + possible_promotions: promotions.select { |promotion| promotion.lane == lane } + ).call item_discounter = ItemDiscounter.new(promotions: lane_promotions) line_item_discounts = adjust_line_items(item_discounter) shipment_discounts = adjust_shipments(item_discounter) diff --git a/spec/models/promotion/integration_spec.rb b/spec/models/promotion/integration_spec.rb index b51dc38d..81c1f257 100644 --- a/spec/models/promotion/integration_spec.rb +++ b/spec/models/promotion/integration_spec.rb @@ -54,12 +54,17 @@ context "with two promotions that should stack" do let(:shirt) { create(:product, name: "Shirt", price: 30) } let(:pants) { create(:product, name: "Pants", price: 40) } + let(:discounted_item_total_rule_amount) { 60 } + let(:discounted_item_total_rule) do + SolidusFriendlyPromotions::Rules::DiscountedItemTotal.new(preferred_amount: discounted_item_total_rule_amount) + end let!(:distributed_amount_promo) do create(:friendly_promotion, :with_adjustable_action, preferred_amount: 10.0, apply_automatically: true, + rules: [discounted_item_total_rule], lane: :post, calculator_class: SolidusFriendlyPromotions::Calculators::DistributedAmount) end @@ -92,6 +97,21 @@ expect(order.item_total_before_tax).to eq(54) expect(order.line_items.flat_map(&:adjustments).length).to eq(3) end + + context "if the post lane promotion is ineligible" do + let(:discounted_item_total_rule_amount) { 68 } + + it "does all the right things" do + expect(order.adjustments).to be_empty + # shirt: 30 USD - 20% = 24 USD + # Remaining total: 64 USD + # The 10 off promotion does not apply because now the order total is below 68 + expect(order.total).to eq(64.00) + expect(order.item_total).to eq(70.00) + expect(order.item_total_before_tax).to eq(64) + expect(order.line_items.flat_map(&:adjustments).length).to eq(1) + end + end end context "with a shipment-level rule" do