diff --git a/promotions/app/models/solidus_promotions/calculators/tiered_flat_rate.rb b/promotions/app/models/solidus_promotions/calculators/tiered_flat_rate.rb index 767eb3d78e..8dcba6de10 100644 --- a/promotions/app/models/solidus_promotions/calculators/tiered_flat_rate.rb +++ b/promotions/app/models/solidus_promotions/calculators/tiered_flat_rate.rb @@ -38,18 +38,11 @@ module Calculators class TieredFlatRate < Spree::Calculator include PromotionCalculator - preference :base_amount, :decimal, default: 0 + preference :base_amount, :decimal, default: Spree::ZERO preference :tiers, :hash, default: { 10 => 10 } preference :currency, :string, default: -> { Spree::Config[:currency] } - before_validation do - # Convert tier values to decimals. Strings don't do us much good. - if preferred_tiers.is_a?(Hash) - self.preferred_tiers = preferred_tiers.map do |key, value| - [cast_to_d(key.to_s), cast_to_d(value.to_s)] - end.to_h - end - end + before_validation :transform_preferred_tiers validate :preferred_tiers_content @@ -95,7 +88,7 @@ def compute_item(object) if preferred_currency.casecmp(object.currency).zero? amount || preferred_base_amount else - 0 + Spree::ZERO end end alias_method :compute_shipment, :compute_item @@ -103,24 +96,21 @@ def compute_item(object) private - # Converts a value to a BigDecimal. + # Transforms preferred_tiers keys and values to BigDecimal for consistent calculations. # - # @param value [String, Numeric] The value to convert - # @return [BigDecimal] The converted decimal value - def cast_to_d(value) - value.to_s.to_d + # Converts all tier threshold keys and percentage values from strings or other numeric + # types to BigDecimal to ensure precision in monetary calculations. + def transform_preferred_tiers + preferred_tiers.transform_keys! { |key| key.to_s.to_d } + preferred_tiers.transform_values! { |value| value.to_s.to_d } end # Validates that preferred_tiers is a hash with positive numeric keys. # # Ensures the tiers preference is properly formatted for tier-based calculations. def preferred_tiers_content - if preferred_tiers.is_a? Hash - unless preferred_tiers.keys.all? { |key| key.is_a?(Numeric) && key > 0 } - errors.add(:base, :keys_should_be_positive_number) - end - else - errors.add(:preferred_tiers, :should_be_hash) + unless preferred_tiers.keys.all? { |key| key.is_a?(Numeric) && key > 0 } + errors.add(:base, :keys_should_be_positive_number) end end end diff --git a/promotions/app/models/solidus_promotions/calculators/tiered_percent.rb b/promotions/app/models/solidus_promotions/calculators/tiered_percent.rb index b5b9319509..04cecb98d7 100644 --- a/promotions/app/models/solidus_promotions/calculators/tiered_percent.rb +++ b/promotions/app/models/solidus_promotions/calculators/tiered_percent.rb @@ -4,28 +4,93 @@ module SolidusPromotions module Calculators + # A calculator that applies tiered percentage-based discounts based on order item total thresholds. + # + # This calculator allows defining multiple discount tiers where each tier specifies a minimum + # order item total threshold and the corresponding percentage discount to apply to the individual + # item. The calculator selects the highest tier that the order qualifies for based on its item total. + # + # Unlike TieredFlatRate which applies a fixed amount, this calculator applies a percentage of the + # item's amount. The tier thresholds are evaluated against the entire order's item total, but the + # percentage discount is applied to the individual item (line item or shipment). + # + # If the order doesn't meet any tier threshold, the base percentage is used. The discount is only + # applied if the currency matches the preferred currency. + # + # @example Use case: Volume-based percentage discounts + # # Higher discounts for larger orders + # calculator = TieredPercent.new( + # preferred_base_percent: 5, + # preferred_tiers: { + # 100 => 10, # 10% off when order total >= $100 + # 250 => 15, # 15% off when order total >= $250 + # 500 => 20 # 20% off when order total >= $500 + # }, + # preferred_currency: 'USD' + # ) + # + # @example Use case: Wholesale tier pricing + # # Different percentage discounts for different order sizes + # calculator = TieredPercent.new( + # preferred_base_percent: 0, + # preferred_tiers: { + # 200 => 5, # 5% wholesale discount at $200 + # 500 => 10, # 10% wholesale discount at $500 + # 1000 => 15 # 15% wholesale discount at $1000 + # }, + # preferred_currency: 'USD' + # ) class TieredPercent < Spree::Calculator include PromotionCalculator - preference :base_percent, :decimal, default: 0 + preference :base_percent, :decimal, default: Spree::ZERO preference :tiers, :hash, default: { 50 => 5 } preference :currency, :string, default: -> { Spree::Config[:currency] } - before_validation do - # Convert tier values to decimals. Strings don't do us much good. - if preferred_tiers.is_a?(Hash) - self.preferred_tiers = preferred_tiers.map do |key, value| - [cast_to_d(key.to_s), cast_to_d(value.to_s)] - end.to_h - end - end + before_validation :transform_preferred_tiers validates :preferred_base_percent, numericality: { - greater_than_or_equal_to: 0, + greater_than_or_equal_to: Spree::ZERO, less_than_or_equal_to: 100 } validate :preferred_tiers_content + # Computes the tiered percentage discount for an item based on the order's item total. + # + # Evaluates the order's item total against all defined tiers and selects the highest + # tier threshold that the order meets or exceeds. Returns a percentage of the item's + # amount based on the matching tier, or the base percentage if no tier threshold is met. + # Returns 0 if the currency doesn't match. + # + # @param object [Object] The object to calculate the discount for (e.g., LineItem, Shipment) + # + # @return [BigDecimal] The percentage-based discount amount, rounded to currency precision + # + # @example Computing discount with tier matching + # calculator = TieredPercent.new( + # preferred_base_percent: 5, + # preferred_tiers: { 100 => 10, 250 => 15 } + # ) + # order.item_total # => 150.00 + # line_item.amount # => 50.00 + # calculator.compute_item(line_item) # => 5.00 (10% of $50, matches $100 tier) + # + # @example Computing discount below all tiers + # calculator = TieredPercent.new( + # preferred_base_percent: 5, + # preferred_tiers: { 100 => 10, 250 => 15 } + # ) + # order.item_total # => 75.00 + # line_item.amount # => 30.00 + # calculator.compute_item(line_item) # => 1.50 (5% base percent of $30) + # + # @example Computing discount with currency mismatch + # calculator = TieredPercent.new( + # preferred_currency: 'USD', + # preferred_tiers: { 100 => 10 } + # ) + # order.currency # => 'EUR' + # calculator.compute_item(line_item) # => 0 def compute_item(object) order = object.order @@ -36,7 +101,7 @@ def compute_item(object) if preferred_currency.casecmp(order.currency).zero? round_to_currency(object.amount * (percent || preferred_base_percent) / 100, preferred_currency) else - 0 + Spree::ZERO end end alias_method :compute_shipment, :compute_item @@ -44,20 +109,27 @@ def compute_item(object) private - def cast_to_d(value) - value.to_s.to_d + # Transforms preferred_tiers keys and values to BigDecimal for consistent calculations. + # + # Converts all tier threshold keys and percentage values from strings or other numeric + # types to BigDecimal to ensure precision in monetary calculations. + def transform_preferred_tiers + preferred_tiers.transform_keys! { |key| key.to_s.to_d } + preferred_tiers.transform_values! { |value| value.to_s.to_d } end + # Validates that preferred_tiers is properly formatted with valid thresholds and percentages. + # + # Ensures: + # - Tiers is a hash + # - All keys (thresholds) are positive numbers + # - All values (percentages) are between 0 and 100 def preferred_tiers_content - if preferred_tiers.is_a? Hash - unless preferred_tiers.keys.all? { |key| key.is_a?(Numeric) && key > 0 } - errors.add(:base, :keys_should_be_positive_number) - end - unless preferred_tiers.values.all? { |key| key.is_a?(Numeric) && key >= 0 && key <= 100 } - errors.add(:base, :values_should_be_percent) - end - else - errors.add(:preferred_tiers, :should_be_hash) + unless preferred_tiers.keys.all? { |key| key.is_a?(Numeric) && key > 0 } + errors.add(:base, :keys_should_be_positive_number) + end + unless preferred_tiers.values.all? { |key| key.is_a?(Numeric) && key >= 0 && key <= 100 } + errors.add(:base, :values_should_be_percent) end end end diff --git a/promotions/app/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity.rb b/promotions/app/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity.rb index e80021c80b..5bd6cd2cb8 100644 --- a/promotions/app/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity.rb +++ b/promotions/app/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity.rb @@ -4,33 +4,119 @@ module SolidusPromotions module Calculators - class TieredPercentOnEligibleItemQuantity < SolidusPromotions::Calculators::TieredPercent + # A calculator that applies tiered percentage discounts based on the total quantity of eligible items. + # + # This calculator defines discount tiers based on the combined quantity of all eligible line items + # in an order (not their monetary value). Each tier specifies a minimum quantity threshold and the + # corresponding percentage discount to apply. The calculator selects the highest tier that the + # order's eligible item quantity meets or exceeds. + # + # The tier thresholds are evaluated against the total quantity of eligible line items, but the + # percentage discount is applied to each individual item's discountable amount. This makes it + # ideal for "buy more, save more" promotions based on item count rather than order value. + # + # If the total eligible quantity doesn't meet any tier threshold, the base percentage is used. + # The discount is only applied if the currency matches the preferred currency. + # + # @example Use case: Bulk quantity discounts + # # Buy 10+ items get 5% off, 25+ get 10% off, 50+ get 15% off + # calculator = TieredPercentOnEligibleItemQuantity.new( + # preferred_base_percent: 0, + # preferred_tiers: { + # 10 => 5, # 5% off when total eligible quantity >= 10 + # 25 => 10, # 10% off when total eligible quantity >= 25 + # 50 => 15 # 15% off when total eligible quantity >= 50 + # }, + # preferred_currency: 'USD' + # ) + # + # @example Use case: Multi-item bundle promotions + # # Encourage buying multiple items from a category + # calculator = TieredPercentOnEligibleItemQuantity.new( + # preferred_base_percent: 0, + # preferred_tiers: { + # 3 => 10, # 10% off when buying 3+ eligible items + # 5 => 15, # 15% off when buying 5+ eligible items + # 10 => 20 # 20% off when buying 10+ eligible items + # }, + # preferred_currency: 'USD' + # ) + class TieredPercentOnEligibleItemQuantity < Spree::Calculator + include PromotionCalculator + + preference :base_percent, :decimal, default: Spree::ZERO preference :tiers, :hash, default: { 10 => 5 } + preference :currency, :string, default: -> { Spree::Config[:currency] } - before_validation do - # Convert tier values to decimals. Strings don't do us much good. - if preferred_tiers.is_a?(Hash) - self.preferred_tiers = preferred_tiers.map do |key, value| - [key.to_i, cast_to_d(value.to_s)] - end.to_h - end - end + before_validation :transform_preferred_tiers - def compute_line_item(line_item) - order = line_item.order + # Computes the tiered percentage discount for an item based on total eligible item quantity. + # + # Evaluates the total quantity of all eligible line items in the order against all defined + # tiers and selects the highest tier threshold that is met or exceeded. Returns a percentage + # of the item's discountable amount based on the matching tier, or the base percentage if no + # tier threshold is met. Returns 0 if the currency doesn't match. + # + # @param item [Object] The object to calculate the discount for (e.g., LineItem, Shipment, ShippingRate) + # + # @return [BigDecimal] The percentage-based discount amount, rounded to currency precision + # + # @example Computing discount with tier matching + # calculator = TieredPercentOnEligibleItemQuantity.new( + # preferred_base_percent: 0, + # preferred_tiers: { 10 => 10, 25 => 15 } + # ) + # # Order has 3 eligible line items with quantities: 5, 6, 4 (total: 15) + # line_item.discountable_amount # => 50.00 + # calculator.compute_item(line_item) # => 5.00 (10% of $50, matches quantity tier of 10) + # + # @example Computing discount below all tiers + # calculator = TieredPercentOnEligibleItemQuantity.new( + # preferred_base_percent: 5, + # preferred_tiers: { 10 => 10, 25 => 15 } + # ) + # # Order has 2 eligible line items with quantities: 3, 4 (total: 7) + # line_item.discountable_amount # => 30.00 + # calculator.compute_item(line_item) # => 1.50 (5% base percent of $30) + # + # @example Computing discount with currency mismatch + # calculator = TieredPercentOnEligibleItemQuantity.new( + # preferred_currency: 'USD', + # preferred_tiers: { 10 => 10 } + # ) + # order.currency # => 'EUR' + # calculator.compute_item(line_item) # => 0 + def compute_item(item) + order = item.order _base, percent = preferred_tiers.sort.reverse.detect do |value, _| eligible_line_items_quantity_total(order) >= value end if preferred_currency.casecmp(order.currency).zero? - round_to_currency(line_item.discountable_amount * (percent || preferred_base_percent) / 100, preferred_currency) + round_to_currency(item.discountable_amount * (percent || preferred_base_percent) / 100, preferred_currency) else - 0 + Spree::ZERO end end + alias_method :compute_shipment, :compute_item + alias_method :compute_shipping_rate, :compute_item + alias_method :compute_line_item, :compute_item private + # Transforms preferred_tiers keys to integers and values to BigDecimal. + # + # Converts tier threshold keys (item quantities) to integers and percentage values + # to BigDecimal for consistent calculations. + def transform_preferred_tiers + preferred_tiers.transform_keys!(&:to_i) + preferred_tiers.transform_values! { |value| value.to_s.to_d } + end + + # Calculates the total quantity of all eligible line items in the order. + # + # @param order [Spree::Order] The order to calculate eligible quantity for + # @return [Integer] The sum of quantities for all applicable line items def eligible_line_items_quantity_total(order) calculable.applicable_line_items(order).sum(&:quantity) end diff --git a/promotions/spec/models/solidus_promotions/calculators/tiered_flat_rate_spec.rb b/promotions/spec/models/solidus_promotions/calculators/tiered_flat_rate_spec.rb index 8307f21143..2b25edbf6f 100644 --- a/promotions/spec/models/solidus_promotions/calculators/tiered_flat_rate_spec.rb +++ b/promotions/spec/models/solidus_promotions/calculators/tiered_flat_rate_spec.rb @@ -63,6 +63,14 @@ end end end + + context "setting tiers to anything but a Hash" do + it "raises TypeError" do + expect { + calculator.preferred_tiers = :no_hash + }.to raise_exception(TypeError) + end + end end describe "#compute" do diff --git a/promotions/spec/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity_spec.rb b/promotions/spec/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity_spec.rb index c9c20ccb6e..bf8fcf58d9 100644 --- a/promotions/spec/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity_spec.rb +++ b/promotions/spec/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity_spec.rb @@ -3,46 +3,71 @@ require "rails_helper" RSpec.describe SolidusPromotions::Calculators::TieredPercentOnEligibleItemQuantity do - let(:order) do - create(:order_with_line_items, line_items_attributes: [first_item_attrs, second_item_attrs, third_item_attrs]) - end + describe "#compute" do + let(:order) do + create(:order_with_line_items, line_items_attributes: [first_item_attrs, second_item_attrs, third_item_attrs]) + end - let(:first_item_attrs) { { variant: shirt, quantity: 2, price: 50 } } - let(:second_item_attrs) { { variant: pants, quantity: 3 } } - let(:third_item_attrs) { { variant: mug, quantity: 1 } } + let(:first_item_attrs) { { variant: shirt, quantity: 2, price: 50 } } + let(:second_item_attrs) { { variant: pants, quantity: 3 } } + let(:third_item_attrs) { { variant: mug, quantity: 1 } } - let(:shirt) { create(:variant) } - let(:pants) { create(:variant) } - let(:mug) { create(:variant) } + let(:shirt) { create(:variant) } + let(:pants) { create(:variant) } + let(:mug) { create(:variant) } - let(:clothes) { create(:taxon, products: [shirt.product, pants.product]) } + let(:clothes) { create(:taxon, products: [shirt.product, pants.product]) } - let(:promotion) { create(:solidus_promotion, name: "10 Percent on 5 apparel, 15 percent on 10", benefits: [benefit]) } - let(:clothes_only) { SolidusPromotions::Conditions::Taxon.new(taxons: [clothes]) } - let(:benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(calculator: calculator, conditions: [clothes_only]) } - let(:calculator) { described_class.new(preferred_base_percent: 10, preferred_tiers: { 10 => 15.0 }) } + let(:promotion) { create(:solidus_promotion, name: "10 Percent on 5 apparel, 15 percent on 10", benefits: [benefit]) } + let(:clothes_only) { SolidusPromotions::Conditions::Taxon.new(taxons: [clothes]) } + let(:benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(calculator: calculator, conditions: [clothes_only]) } + let(:calculator) { described_class.new(preferred_base_percent: 10, preferred_tiers: { 10 => 15.0 }) } - let(:line_item) { order.line_items.detect { _1.variant == shirt } } + let(:item) { order.line_items.detect { _1.variant == shirt } } - subject { promotion.benefits.first.calculator.compute(line_item) } + subject { promotion.benefits.first.calculator.compute(item) } - # 2 Shirts at 50, 100 USD. 10 % == 10 - it { is_expected.to eq(10) } + # 2 Shirts at 50, 100 USD. 10 % == 10 + it { is_expected.to eq(10) } - context "if we have 12" do - let(:first_item_attrs) { { variant: shirt, quantity: 7, price: 50 } } - let(:second_item_attrs) { { variant: pants, quantity: 5 } } + context "if we have 12" do + let(:first_item_attrs) { { variant: shirt, quantity: 7, price: 50 } } + let(:second_item_attrs) { { variant: pants, quantity: 5 } } - # 7 Shirts at 50, 350 USD, 15 % == 52.5 - it { is_expected.to eq(52.5) } - end + # 7 Shirts at 50, 350 USD, 15 % == 52.5 + it { is_expected.to eq(52.5) } + end + + context "if the order's currency is different" do + before do + order.currency = "GBP" + order.save! + end + + it { is_expected.to eq(0) } + end - context "if the order's currency is different" do - before do - order.currency = "GBP" - order.save! + context "discounting a shipment" do + let(:item) { build(:shipment, order:, cost: 29) } + + it { is_expected.to eq(2.9) } end - it { is_expected.to eq(0) } + context "discounting a shipping rate" do + let(:shipment) { build(:shipment, order:) } + let(:item) { build(:shipping_rate, shipment:, cost: 38) } + + it { is_expected.to eq(3.8) } + end + end + + describe "setting tiers to anything but a Hash" do + let(:calculator) { described_class.new } + + it "raises TypeError" do + expect { + calculator.preferred_tiers = :no_hash + }.to raise_exception(TypeError) + end end end diff --git a/promotions/spec/models/solidus_promotions/calculators/tiered_percent_spec.rb b/promotions/spec/models/solidus_promotions/calculators/tiered_percent_spec.rb index 88ccf6ef27..1e9e261dbb 100644 --- a/promotions/spec/models/solidus_promotions/calculators/tiered_percent_spec.rb +++ b/promotions/spec/models/solidus_promotions/calculators/tiered_percent_spec.rb @@ -81,6 +81,14 @@ end end end + + context "setting tiers to anything but a Hash" do + it "raises TypeError" do + expect { + calculator.preferred_tiers = :no_hash + }.to raise_exception(TypeError) + end + end end describe "#compute" do