Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -95,32 +88,29 @@ 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
alias_method :compute_line_item, :compute_item

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -36,28 +101,35 @@ 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
alias_method :compute_line_item, :compute_item

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading