solidus_promotions 4.6.2 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/MIGRATING.md +10 -4
  3. data/README.md +7 -0
  4. data/app/javascript/backend/solidus_promotions/controllers/product_option_values_controller.js +3 -26
  5. data/app/javascript/backend/solidus_promotions/web_components/option_value_picker.js +36 -19
  6. data/app/javascript/backend/solidus_promotions/web_components/product_picker.js +6 -1
  7. data/app/jobs/solidus_promotions/promotion_code_batch_job.rb +1 -1
  8. data/app/models/concerns/solidus_promotions/adjustment_discounts.rb +20 -0
  9. data/app/models/concerns/solidus_promotions/benefits/line_item_benefit.rb +5 -0
  10. data/app/models/concerns/solidus_promotions/benefits/order_benefit.rb +5 -0
  11. data/app/models/concerns/solidus_promotions/benefits/shipment_benefit.rb +5 -0
  12. data/app/models/concerns/solidus_promotions/calculators/promotion_calculator.rb +7 -0
  13. data/app/models/concerns/solidus_promotions/conditions/line_item_applicable_order_level_condition.rb +17 -5
  14. data/app/models/concerns/solidus_promotions/conditions/line_item_level_condition.rb +15 -2
  15. data/app/models/concerns/solidus_promotions/conditions/option_value_condition.rb +21 -0
  16. data/app/models/concerns/solidus_promotions/conditions/order_level_condition.rb +15 -2
  17. data/app/models/concerns/solidus_promotions/conditions/product_condition.rb +28 -0
  18. data/app/models/concerns/solidus_promotions/conditions/shipment_level_condition.rb +15 -2
  19. data/app/models/concerns/solidus_promotions/conditions/taxon_condition.rb +77 -0
  20. data/app/models/concerns/solidus_promotions/coupon_code_normalizer.rb +37 -0
  21. data/app/models/concerns/solidus_promotions/discountable_amount.rb +3 -4
  22. data/app/models/concerns/solidus_promotions/discounted_amount.rb +54 -0
  23. data/app/models/solidus_promotions/benefit.rb +257 -36
  24. data/app/models/solidus_promotions/benefits/adjust_line_item.rb +28 -3
  25. data/app/models/solidus_promotions/benefits/adjust_line_item_quantity_groups.rb +1 -0
  26. data/app/models/solidus_promotions/benefits/adjust_shipment.rb +45 -3
  27. data/app/models/solidus_promotions/benefits/advertise_price.rb +28 -0
  28. data/app/models/solidus_promotions/benefits/create_discounted_item.rb +30 -7
  29. data/app/models/solidus_promotions/calculators/distributed_amount.rb +34 -8
  30. data/app/models/solidus_promotions/calculators/flat_rate.rb +52 -6
  31. data/app/models/solidus_promotions/calculators/flexi_rate.rb +69 -6
  32. data/app/models/solidus_promotions/calculators/percent.rb +40 -4
  33. data/app/models/solidus_promotions/calculators/percent_with_cap.rb +44 -3
  34. data/app/models/solidus_promotions/calculators/tiered_flat_rate.rb +81 -19
  35. data/app/models/solidus_promotions/calculators/tiered_percent.rb +96 -25
  36. data/app/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity.rb +101 -16
  37. data/app/models/solidus_promotions/condition.rb +186 -7
  38. data/app/models/solidus_promotions/conditions/first_order.rb +3 -1
  39. data/app/models/solidus_promotions/conditions/first_repeat_purchase_since.rb +3 -2
  40. data/app/models/solidus_promotions/conditions/item_total.rb +2 -1
  41. data/app/models/solidus_promotions/conditions/line_item_option_value.rb +4 -12
  42. data/app/models/solidus_promotions/conditions/line_item_product.rb +4 -22
  43. data/app/models/solidus_promotions/conditions/line_item_taxon.rb +7 -38
  44. data/app/models/solidus_promotions/conditions/minimum_quantity.rb +3 -2
  45. data/app/models/solidus_promotions/conditions/nth_order.rb +3 -2
  46. data/app/models/solidus_promotions/conditions/one_use_per_user.rb +2 -1
  47. data/app/models/solidus_promotions/conditions/option_value.rb +6 -11
  48. data/app/models/solidus_promotions/conditions/order_option_value.rb +19 -0
  49. data/app/models/solidus_promotions/conditions/order_product.rb +62 -0
  50. data/app/models/solidus_promotions/conditions/order_taxon.rb +60 -0
  51. data/app/models/solidus_promotions/conditions/price_option_value.rb +26 -0
  52. data/app/models/solidus_promotions/conditions/price_product.rb +36 -0
  53. data/app/models/solidus_promotions/conditions/price_taxon.rb +28 -0
  54. data/app/models/solidus_promotions/conditions/product.rb +17 -59
  55. data/app/models/solidus_promotions/conditions/shipping_method.rb +3 -5
  56. data/app/models/solidus_promotions/conditions/store.rb +2 -1
  57. data/app/models/solidus_promotions/conditions/taxon.rb +24 -73
  58. data/app/models/solidus_promotions/conditions/user.rb +2 -1
  59. data/app/models/solidus_promotions/conditions/user_logged_in.rb +1 -3
  60. data/app/models/solidus_promotions/conditions/user_role.rb +1 -3
  61. data/app/models/solidus_promotions/distributed_amounts_handler.rb +2 -6
  62. data/app/models/solidus_promotions/eligibility_results.rb +1 -0
  63. data/app/models/solidus_promotions/item_discount.rb +1 -0
  64. data/app/models/solidus_promotions/order_adjuster/discount_order.rb +29 -35
  65. data/app/models/solidus_promotions/order_adjuster/recalculate_promo_totals.rb +45 -0
  66. data/app/models/solidus_promotions/order_adjuster/set_discounts_to_zero.rb +33 -0
  67. data/app/models/solidus_promotions/order_adjuster.rb +4 -14
  68. data/app/models/solidus_promotions/order_promotion.rb +1 -0
  69. data/app/models/solidus_promotions/product_advertiser.rb +57 -0
  70. data/app/models/solidus_promotions/promotion.rb +12 -10
  71. data/app/models/solidus_promotions/promotion_code/batch_builder.rb +1 -1
  72. data/app/models/solidus_promotions/promotion_code.rb +4 -4
  73. data/app/models/solidus_promotions/promotion_code_batch.rb +1 -1
  74. data/app/models/solidus_promotions/promotion_handler/coupon.rb +1 -1
  75. data/app/models/solidus_promotions/promotion_handler/page.rb +1 -1
  76. data/app/models/solidus_promotions/promotion_lane.rb +48 -0
  77. data/app/models/solidus_promotions/shipping_rate_discount.rb +3 -0
  78. data/app/patches/models/solidus_promotions/line_item_patch.rb +2 -0
  79. data/app/patches/models/solidus_promotions/order_patch.rb +8 -0
  80. data/app/patches/models/solidus_promotions/order_recalculator_patch.rb +3 -1
  81. data/app/patches/models/solidus_promotions/price_patch.rb +31 -0
  82. data/app/patches/models/solidus_promotions/shipment_patch.rb +2 -0
  83. data/app/patches/models/solidus_promotions/shipping_rate_patch.rb +15 -0
  84. data/config/locales/en.yml +47 -11
  85. data/config/routes.rb +1 -1
  86. data/db/migrate/20230703101637_create_promotions.rb +2 -2
  87. data/db/migrate/20230703113625_create_promotion_benefits.rb +3 -3
  88. data/db/migrate/20230703141116_create_promotion_categories.rb +1 -1
  89. data/db/migrate/20230703143943_create_promotion_conditions.rb +1 -1
  90. data/db/migrate/20230704083830_add_condition_join_tables.rb +8 -8
  91. data/db/migrate/20230704102444_create_promotion_codes.rb +1 -1
  92. data/db/migrate/20230704102656_create_promotion_code_batches.rb +1 -1
  93. data/db/migrate/20230705171556_create_order_promotions.rb +3 -3
  94. data/db/migrate/20230725074235_create_shipping_rate_discounts.rb +2 -2
  95. data/db/migrate/20231104135812_add_managed_by_order_benefit_to_line_items.rb +1 -1
  96. data/db/migrate/20251104170913_update_promotion_code_value_collation.rb +38 -0
  97. data/db/migrate/20251104214304_separate_out_order_only_conditions.rb +41 -0
  98. data/eslint.config.mjs +29 -0
  99. data/lib/components/admin/solidus_promotions/promotion_categories/index/component.rb +6 -6
  100. data/lib/components/admin/solidus_promotions/promotions/index/component.rb +5 -5
  101. data/lib/solidus_promotions/configuration.rb +57 -12
  102. data/lib/solidus_promotions/promotion_map.rb +14 -14
  103. data/lib/solidus_promotions/testing_support/shared_examples/option_value_condition.rb +18 -0
  104. data/lib/solidus_promotions/testing_support/shared_examples/product_condition.rb +37 -0
  105. data/lib/solidus_promotions/testing_support/shared_examples/promotion_calculator.rb +11 -0
  106. data/lib/solidus_promotions/testing_support/shared_examples/taxon_condition.rb +37 -0
  107. data/lib/solidus_promotions/testing_support/shared_examples.rb +6 -0
  108. data/lib/views/backend/solidus_promotions/admin/benefit_fields/_advertise_price.html.erb +7 -0
  109. data/lib/views/backend/solidus_promotions/admin/calculator_fields/_default_fields.html.erb +1 -1
  110. data/lib/views/backend/solidus_promotions/admin/calculator_fields/percent/_fields.html.erb +1 -1
  111. data/lib/views/backend/solidus_promotions/admin/condition_fields/_line_item_option_value.html.erb +6 -5
  112. data/lib/views/backend/solidus_promotions/admin/condition_fields/_option_value.html.erb +6 -12
  113. data/lib/views/backend/solidus_promotions/admin/condition_fields/_price_option_value.html.erb +26 -0
  114. data/lib/views/backend/solidus_promotions/admin/condition_fields/_price_product.html.erb +21 -0
  115. data/lib/views/backend/solidus_promotions/admin/condition_fields/_price_taxon.html.erb +17 -0
  116. data/lib/views/backend/solidus_promotions/admin/condition_fields/_product.html.erb +0 -7
  117. data/lib/views/backend/solidus_promotions/admin/condition_fields/_taxon.html.erb +0 -7
  118. data/lib/views/backend/solidus_promotions/admin/condition_fields/line_item_option_value/_option_value_fields.html.erb +10 -4
  119. data/solidus_promotions.gemspec +1 -1
  120. metadata +37 -6
  121. data/.eslintrc.json +0 -10
  122. data/app/models/solidus_promotions/order_adjuster/persist_discounted_order.rb +0 -79
@@ -4,20 +4,66 @@ require_dependency "spree/calculator"
4
4
 
5
5
  module SolidusPromotions
6
6
  module Calculators
7
+ # A calculator that applies a flat rate discount amount.
8
+ #
9
+ # This calculator returns a fixed discount amount if the item's order currency
10
+ # matches the preferred currency, otherwise it returns zero.
7
11
  class FlatRate < Spree::Calculator
8
12
  include PromotionCalculator
9
13
 
10
- preference :amount, :decimal, default: 0
14
+ preference :amount, :decimal, default: Spree::ZERO
11
15
  preference :currency, :string, default: -> { Spree::Config[:currency] }
12
16
 
13
- def compute(object = nil)
14
- currency = object.order.currency
15
- if object && preferred_currency.casecmp(currency).zero?
16
- preferred_amount
17
+ # Computes the discount amount for an item.
18
+ #
19
+ # Returns the preferred amount if the item's order currency matches the
20
+ # preferred currency, otherwise returns 0.
21
+ #
22
+ # @param item [Object] The item to calculate the discount for (e.g., LineItem, Shipment, ShippingRate)
23
+ #
24
+ # @return [BigDecimal] The discount amount (preferred_amount if currency matches, 0 otherwise)
25
+ #
26
+ # @example Computing discount for a line item with matching currency
27
+ # calculator = FlatRate.new(preferred_amount: 10, preferred_currency: 'USD')
28
+ # line_item.order.currency # => 'USD'
29
+ # calculator.compute_item(line_item) # => 10.0
30
+ #
31
+ # @example Computing discount for a line item with non-matching currency
32
+ # calculator = FlatRate.new(preferred_amount: 10, preferred_currency: 'USD')
33
+ # line_item.order.currency # => 'EUR'
34
+ # calculator.compute_item(line_item) # => 0
35
+ def compute_item(item)
36
+ currency = item.order.currency
37
+ if item && preferred_currency.casecmp(currency).zero?
38
+ compute_for_amount(item.discountable_amount)
17
39
  else
18
- 0
40
+ Spree::ZERO
19
41
  end
20
42
  end
43
+ alias_method :compute_line_item, :compute_item
44
+ alias_method :compute_shipment, :compute_item
45
+ alias_method :compute_shipping_rate, :compute_item
46
+
47
+ def compute_price(price, options = {})
48
+ order = options[:order]
49
+ quantity = options[:quantity]
50
+ return preferred_amount unless order
51
+ return Spree::ZERO if order.currency != preferred_currency
52
+ line_item_with_variant = order.line_items.detect { _1.variant == price.variant }
53
+ desired_extra_amount = quantity * price.discountable_amount
54
+ current_discounted_amount = line_item_with_variant ? line_item_with_variant.discountable_amount : Spree::ZERO
55
+ round_to_currency(
56
+ (compute_for_amount(current_discounted_amount + desired_extra_amount.to_f) -
57
+ compute_for_amount(current_discounted_amount)) / quantity,
58
+ preferred_currency
59
+ )
60
+ end
61
+
62
+ private
63
+
64
+ def compute_for_amount(amount)
65
+ [amount, preferred_amount].min
66
+ end
21
67
  end
22
68
  end
23
69
  end
@@ -4,19 +4,82 @@ require_dependency "spree/calculator"
4
4
 
5
5
  module SolidusPromotions
6
6
  module Calculators
7
+ # A calculator that applies different discount amounts for the first item and additional items.
8
+ #
9
+ # This calculator allows setting a discount for the first item in a line item and a
10
+ # different discount for each additional item. Optionally, a maximum number of items
11
+ # can be specified to limit the discount calculation.
12
+ #
13
+ # @example
14
+ # # $5 off first item, $2 off each additional item, max 5 items
15
+ # calculator = FlexiRate.new(
16
+ # preferred_first_item: 5,
17
+ # preferred_additional_item: 2,
18
+ # preferred_max_items: 5
19
+ # )
20
+ # # Line item with quantity 3: $5 + ($2 × 2) = $9 discount
21
+ # # Line item with quantity 10: $5 + ($2 × 4) = $13 discount (limited to 5 items)
7
22
  class FlexiRate < Spree::Calculator
8
23
  include PromotionCalculator
9
24
 
10
- preference :first_item, :decimal, default: 0
11
- preference :additional_item, :decimal, default: 0
25
+ preference :first_item, :decimal, default: Spree::ZERO
26
+ preference :additional_item, :decimal, default: Spree::ZERO
12
27
  preference :max_items, :integer, default: 0
13
28
  preference :currency, :string, default: -> { Spree::Config[:currency] }
14
29
 
15
- def compute(object)
16
- items_count = object.quantity
17
- items_count = [items_count, preferred_max_items].min unless preferred_max_items.zero?
30
+ # Computes the discount amount for a line item based on its quantity.
31
+ #
32
+ # Calculates the total discount by applying the first_item rate to the first unit
33
+ # and the additional_item rate to remaining units. If max_items is set (non-zero),
34
+ # the calculation is limited to that number of items.
35
+ #
36
+ # @param line_item [Spree::LineItem] The line item to calculate the discount for
37
+ #
38
+ # @return [BigDecimal] The total discount amount based on quantity and preferences
39
+ #
40
+ # @example Computing discount for a line item with 3 items
41
+ # calculator = FlexiRate.new(preferred_first_item: 10, preferred_additional_item: 5)
42
+ # line_item.quantity # => 3
43
+ # calculator.compute_line_item(line_item) # => 20.0 (10 + 5 + 5)
44
+ #
45
+ # @example Computing discount with max_items limit
46
+ # calculator = FlexiRate.new(
47
+ # preferred_first_item: 10,
48
+ # preferred_additional_item: 5,
49
+ # preferred_max_items: 2
50
+ # )
51
+ # line_item.quantity # => 5
52
+ # calculator.compute_line_item(line_item) # => 15.0 (10 + 5, limited to 2 items)
53
+ def compute_line_item(line_item)
54
+ compute_for_quantity(line_item.quantity)
55
+ end
56
+
57
+ def compute_price(price, options = {})
58
+ order = options[:order]
59
+ desired_quantity = options[:quantity] || 0
60
+ return Spree::ZERO if desired_quantity.zero?
61
+
62
+ already_ordered_quantity = if order
63
+ order.line_items.detect do |line_item|
64
+ line_item.variant == price.variant
65
+ end&.quantity || 0
66
+ else
67
+ 0
68
+ end
69
+ possible_discount = compute_for_quantity(already_ordered_quantity + desired_quantity)
70
+ existing_discount = compute_for_quantity(already_ordered_quantity)
71
+ round_to_currency(
72
+ (possible_discount - existing_discount) / desired_quantity,
73
+ price.currency
74
+ )
75
+ end
76
+
77
+ private
78
+
79
+ def compute_for_quantity(quantity)
80
+ items_count = preferred_max_items.zero? ? quantity : [quantity, preferred_max_items].min
18
81
 
19
- return Spree::ZERO if items_count == 0
82
+ return Spree::ZERO if items_count.zero?
20
83
 
21
84
  additional_items_count = items_count - 1
22
85
  preferred_first_item + preferred_additional_item * additional_items_count
@@ -4,16 +4,52 @@ require_dependency "spree/calculator"
4
4
 
5
5
  module SolidusPromotions
6
6
  module Calculators
7
+ # A calculator that applies a percentage-based discount.
8
+ #
9
+ # This calculator computes the discount as a percentage of the item's discountable amount,
10
+ # rounded to the appropriate currency precision.
11
+ #
12
+ # @example
13
+ # calculator = Percent.new(preferred_percent: 15)
14
+ # # Line item with discountable_amount of $100
15
+ # calculator.compute_item(line_item) # => 15.00 (15% of $100)
7
16
  class Percent < Spree::Calculator
8
17
  include PromotionCalculator
9
18
 
10
19
  preference :percent, :decimal, default: 0
11
20
 
12
- def compute(object)
13
- preferred_currency = object.order.currency
14
- currency_exponent = ::Money::Currency.find(preferred_currency).exponent
15
- (object.discountable_amount * preferred_percent / 100).round(currency_exponent)
21
+ # Computes the percentage-based discount for an item.
22
+ #
23
+ # Calculates the discount by applying the preferred percentage to the item's
24
+ # discountable amount, then rounds the result to the appropriate precision
25
+ # for the order's currency.
26
+ #
27
+ # @param object [Object] The object to calculate the discount for (e.g., LineItem, Shipment, ShippingRate)
28
+ #
29
+ # @return [BigDecimal] The discount amount, rounded to the order's currency precision
30
+ #
31
+ # @example Computing a 20% discount on a $50 line item
32
+ # calculator = Percent.new(preferred_percent: 20)
33
+ # line_item.discountable_amount # => 50.00
34
+ # calculator.compute_item(line_item) # => 10.00
35
+ #
36
+ # @example Computing a 15% discount on a shipment
37
+ # calculator = Percent.new(preferred_percent: 15)
38
+ # shipment.discountable_amount # => 25.00
39
+ # calculator.compute_item(shipment) # => 3.75
40
+ #
41
+ # @example Computing a 15% discount on a price
42
+ # calculator = Percent.new(preferred_percent: 15)
43
+ # price.discountable_amount # => 100.00
44
+ # calculator.compute_item(shipment) # => 15
45
+ def compute_item(object, _options = {})
46
+ currency = object.respond_to?(:currency) ? object.currency : object.order.currency
47
+ round_to_currency(object.discountable_amount * preferred_percent / 100, currency)
16
48
  end
49
+ alias_method :compute_line_item, :compute_item
50
+ alias_method :compute_shipment, :compute_item
51
+ alias_method :compute_shipping_rate, :compute_item
52
+ alias_method :compute_price, :compute_item
17
53
  end
18
54
  end
19
55
  end
@@ -2,11 +2,52 @@
2
2
 
3
3
  module SolidusPromotions
4
4
  module Calculators
5
- class PercentWithCap < Percent
5
+ # A calculator that applies a percentage-based discount with a maximum cap.
6
+ #
7
+ # This calculator computes a discount as a percentage of the line item's discountable amount,
8
+ # but limits the total discount to a maximum amount distributed across all applicable line items.
9
+ # The actual discount applied is the lesser of the percentage discount and the proportional
10
+ # share of the maximum cap.
11
+ #
12
+ # @example
13
+ # calculator = PercentWithCap.new(preferred_percent: 20, preferred_max_amount: 50)
14
+ # # Line item with $100 discountable amount
15
+ # # Percentage would be $20 (20% of $100)
16
+ # # But if the max cap distributes only $15 to this item, it gets $15
17
+ class PercentWithCap < Spree::Calculator
18
+ include PromotionCalculator
19
+
20
+ preference :percent, :decimal, default: 0
6
21
  preference :max_amount, :integer, default: 100
7
22
 
8
- def compute(line_item)
9
- percent_discount = super
23
+ # Computes the discount for a line item, capped at a maximum amount.
24
+ #
25
+ # Calculates both a percentage-based discount and a distributed maximum discount,
26
+ # then returns whichever is smaller. This ensures the discount never exceeds
27
+ # the line item's proportional share of the maximum cap, even if the percentage
28
+ # would result in a larger discount.
29
+ #
30
+ # @param line_item [Spree::LineItem] The line item to calculate the discount for
31
+ #
32
+ # @return [BigDecimal] The discount amount, limited by both the percentage and the max cap
33
+ #
34
+ # @example Computing discount when percentage is lower than cap
35
+ # calculator = PercentWithCap.new(preferred_percent: 10, preferred_max_amount: 100)
36
+ # line_item.discountable_amount # => 50.00
37
+ # # Percent discount: $5 (10% of $50)
38
+ # # Max distributed: $25 (assuming equal distribution)
39
+ # calculator.compute_line_item(line_item) # => 5.00
40
+ #
41
+ # @example Computing discount when cap is lower than percentage
42
+ # calculator = PercentWithCap.new(preferred_percent: 50, preferred_max_amount: 10)
43
+ # line_item.discountable_amount # => 100.00
44
+ # # Percent discount: $50 (50% of $100)
45
+ # # Max distributed: $10 (assuming single line item)
46
+ # calculator.compute_line_item(line_item) # => 10.00
47
+ #
48
+ # @see DistributedAmount
49
+ def compute_line_item(line_item)
50
+ percent_discount = round_to_currency(line_item.discountable_amount * preferred_percent / 100, line_item.order.currency)
10
51
  max_discount = DistributedAmount.new(
11
52
  calculable:,
12
53
  preferred_amount: preferred_max_amount
@@ -4,24 +4,82 @@ require_dependency "spree/calculator"
4
4
 
5
5
  module SolidusPromotions
6
6
  module Calculators
7
+ # A calculator that applies tiered flat-rate discounts based on discountable amount thresholds.
8
+ #
9
+ # This calculator allows defining multiple discount tiers where each tier specifies a minimum
10
+ # discountable amount threshold and the corresponding discount amount to apply. The calculator
11
+ # selects the highest tier that the item qualifies for based on its discountable amount.
12
+ #
13
+ # If the item doesn't meet any tier threshold, the base amount is used. The discount is only
14
+ # applied if the currency matches the preferred currency.
15
+ #
16
+ # @example Use case: Volume-based shipping discounts
17
+ # # Free shipping on orders over $100, $5 off on orders over $50
18
+ # calculator = TieredFlatRate.new(
19
+ # preferred_base_amount: 0,
20
+ # preferred_tiers: {
21
+ # 50 => 5, # $5 discount when amount >= $50
22
+ # 100 => 15 # $15 discount when amount >= $100
23
+ # },
24
+ # preferred_currency: 'USD'
25
+ # )
26
+ #
27
+ # @example Use case: Bulk purchase incentives
28
+ # # Tiered discounts for line items based on total line value
29
+ # calculator = TieredFlatRate.new(
30
+ # preferred_base_amount: 2,
31
+ # preferred_tiers: {
32
+ # 25 => 5, # $5 off when line total >= $25
33
+ # 50 => 12, # $12 off when line total >= $50
34
+ # 100 => 30 # $30 off when line total >= $100
35
+ # },
36
+ # preferred_currency: 'USD'
37
+ # )
7
38
  class TieredFlatRate < Spree::Calculator
8
39
  include PromotionCalculator
9
40
 
10
- preference :base_amount, :decimal, default: 0
11
- preference :tiers, :hash, default: { 10 => 10 }
41
+ preference :base_amount, :decimal, default: Spree::ZERO
42
+ preference :tiers, :hash, default: {10 => 10}
12
43
  preference :currency, :string, default: -> { Spree::Config[:currency] }
13
44
 
14
- before_validation do
15
- # Convert tier values to decimals. Strings don't do us much good.
16
- if preferred_tiers.is_a?(Hash)
17
- self.preferred_tiers = preferred_tiers.map do |key, value|
18
- [cast_to_d(key.to_s), cast_to_d(value.to_s)]
19
- end.to_h
20
- end
21
- end
45
+ before_validation :transform_preferred_tiers
22
46
 
23
47
  validate :preferred_tiers_content
24
48
 
49
+ # Computes the tiered flat-rate discount for an item.
50
+ #
51
+ # Evaluates the item's discountable amount against all defined tiers and selects
52
+ # the highest tier threshold that the item meets or exceeds. Returns the discount
53
+ # amount associated with that tier, or the base amount if no tier threshold is met.
54
+ # Returns 0 if the currency doesn't match.
55
+ #
56
+ # @param object [Object] The object to calculate the discount for (e.g., LineItem, Shipment)
57
+ #
58
+ # @return [BigDecimal] The discount amount from the matching tier, base amount, or 0
59
+ #
60
+ # @example Computing discount with tier matching
61
+ # calculator = TieredFlatRate.new(
62
+ # preferred_base_amount: 2,
63
+ # preferred_tiers: { 25 => 5, 50 => 10, 100 => 20 }
64
+ # )
65
+ # line_item.discountable_amount # => 75.00
66
+ # calculator.compute_item(line_item) # => 10.00 (matches $50 tier)
67
+ #
68
+ # @example Computing discount below all tiers
69
+ # calculator = TieredFlatRate.new(
70
+ # preferred_base_amount: 2,
71
+ # preferred_tiers: { 25 => 5, 50 => 10 }
72
+ # )
73
+ # line_item.discountable_amount # => 15.00
74
+ # calculator.compute_item(line_item) # => 2.00 (base amount)
75
+ #
76
+ # @example Computing discount with currency mismatch
77
+ # calculator = TieredFlatRate.new(
78
+ # preferred_currency: 'USD',
79
+ # preferred_tiers: { 50 => 10 }
80
+ # )
81
+ # line_item.currency # => 'EUR'
82
+ # calculator.compute_item(line_item) # => 0
25
83
  def compute_item(object)
26
84
  _base, amount = preferred_tiers.sort.reverse.detect do |value, _|
27
85
  object.discountable_amount >= value
@@ -30,7 +88,7 @@ module SolidusPromotions
30
88
  if preferred_currency.casecmp(object.currency).zero?
31
89
  amount || preferred_base_amount
32
90
  else
33
- 0
91
+ Spree::ZERO
34
92
  end
35
93
  end
36
94
  alias_method :compute_shipment, :compute_item
@@ -38,17 +96,21 @@ module SolidusPromotions
38
96
 
39
97
  private
40
98
 
41
- def cast_to_d(value)
42
- value.to_s.to_d
99
+ # Transforms preferred_tiers keys and values to BigDecimal for consistent calculations.
100
+ #
101
+ # Converts all tier threshold keys and percentage values from strings or other numeric
102
+ # types to BigDecimal to ensure precision in monetary calculations.
103
+ def transform_preferred_tiers
104
+ preferred_tiers.transform_keys! { |key| key.to_s.to_d }
105
+ preferred_tiers.transform_values! { |value| value.to_s.to_d }
43
106
  end
44
107
 
108
+ # Validates that preferred_tiers is a hash with positive numeric keys.
109
+ #
110
+ # Ensures the tiers preference is properly formatted for tier-based calculations.
45
111
  def preferred_tiers_content
46
- if preferred_tiers.is_a? Hash
47
- unless preferred_tiers.keys.all? { |key| key.is_a?(Numeric) && key > 0 }
48
- errors.add(:base, :keys_should_be_positive_number)
49
- end
50
- else
51
- errors.add(:preferred_tiers, :should_be_hash)
112
+ unless preferred_tiers.keys.all? { |key| key.is_a?(Numeric) && key > 0 }
113
+ errors.add(:base, :keys_should_be_positive_number)
52
114
  end
53
115
  end
54
116
  end
@@ -4,28 +4,93 @@ require_dependency "spree/calculator"
4
4
 
5
5
  module SolidusPromotions
6
6
  module Calculators
7
+ # A calculator that applies tiered percentage-based discounts based on order item total thresholds.
8
+ #
9
+ # This calculator allows defining multiple discount tiers where each tier specifies a minimum
10
+ # order item total threshold and the corresponding percentage discount to apply to the individual
11
+ # item. The calculator selects the highest tier that the order qualifies for based on its item total.
12
+ #
13
+ # Unlike TieredFlatRate which applies a fixed amount, this calculator applies a percentage of the
14
+ # item's amount. The tier thresholds are evaluated against the entire order's item total, but the
15
+ # percentage discount is applied to the individual item (line item or shipment).
16
+ #
17
+ # If the order doesn't meet any tier threshold, the base percentage is used. The discount is only
18
+ # applied if the currency matches the preferred currency.
19
+ #
20
+ # @example Use case: Volume-based percentage discounts
21
+ # # Higher discounts for larger orders
22
+ # calculator = TieredPercent.new(
23
+ # preferred_base_percent: 5,
24
+ # preferred_tiers: {
25
+ # 100 => 10, # 10% off when order total >= $100
26
+ # 250 => 15, # 15% off when order total >= $250
27
+ # 500 => 20 # 20% off when order total >= $500
28
+ # },
29
+ # preferred_currency: 'USD'
30
+ # )
31
+ #
32
+ # @example Use case: Wholesale tier pricing
33
+ # # Different percentage discounts for different order sizes
34
+ # calculator = TieredPercent.new(
35
+ # preferred_base_percent: 0,
36
+ # preferred_tiers: {
37
+ # 200 => 5, # 5% wholesale discount at $200
38
+ # 500 => 10, # 10% wholesale discount at $500
39
+ # 1000 => 15 # 15% wholesale discount at $1000
40
+ # },
41
+ # preferred_currency: 'USD'
42
+ # )
7
43
  class TieredPercent < Spree::Calculator
8
44
  include PromotionCalculator
9
45
 
10
- preference :base_percent, :decimal, default: 0
11
- preference :tiers, :hash, default: { 50 => 5 }
46
+ preference :base_percent, :decimal, default: Spree::ZERO
47
+ preference :tiers, :hash, default: {50 => 5}
12
48
  preference :currency, :string, default: -> { Spree::Config[:currency] }
13
49
 
14
- before_validation do
15
- # Convert tier values to decimals. Strings don't do us much good.
16
- if preferred_tiers.is_a?(Hash)
17
- self.preferred_tiers = preferred_tiers.map do |key, value|
18
- [cast_to_d(key.to_s), cast_to_d(value.to_s)]
19
- end.to_h
20
- end
21
- end
50
+ before_validation :transform_preferred_tiers
22
51
 
23
52
  validates :preferred_base_percent, numericality: {
24
- greater_than_or_equal_to: 0,
53
+ greater_than_or_equal_to: Spree::ZERO,
25
54
  less_than_or_equal_to: 100
26
55
  }
27
56
  validate :preferred_tiers_content
28
57
 
58
+ # Computes the tiered percentage discount for an item based on the order's item total.
59
+ #
60
+ # Evaluates the order's item total against all defined tiers and selects the highest
61
+ # tier threshold that the order meets or exceeds. Returns a percentage of the item's
62
+ # amount based on the matching tier, or the base percentage if no tier threshold is met.
63
+ # Returns 0 if the currency doesn't match.
64
+ #
65
+ # @param object [Object] The object to calculate the discount for (e.g., LineItem, Shipment)
66
+ #
67
+ # @return [BigDecimal] The percentage-based discount amount, rounded to currency precision
68
+ #
69
+ # @example Computing discount with tier matching
70
+ # calculator = TieredPercent.new(
71
+ # preferred_base_percent: 5,
72
+ # preferred_tiers: { 100 => 10, 250 => 15 }
73
+ # )
74
+ # order.item_total # => 150.00
75
+ # line_item.amount # => 50.00
76
+ # calculator.compute_item(line_item) # => 5.00 (10% of $50, matches $100 tier)
77
+ #
78
+ # @example Computing discount below all tiers
79
+ # calculator = TieredPercent.new(
80
+ # preferred_base_percent: 5,
81
+ # preferred_tiers: { 100 => 10, 250 => 15 }
82
+ # )
83
+ # order.item_total # => 75.00
84
+ # line_item.amount # => 30.00
85
+ # calculator.compute_item(line_item) # => 1.50 (5% base percent of $30)
86
+ #
87
+ # @example Computing discount with currency mismatch
88
+ # calculator = TieredPercent.new(
89
+ # preferred_currency: 'USD',
90
+ # preferred_tiers: { 100 => 10 }
91
+ # )
92
+ # order.currency # => 'EUR'
93
+ # calculator.compute_item(line_item) # => 0
29
94
  def compute_item(object)
30
95
  order = object.order
31
96
 
@@ -34,10 +99,9 @@ module SolidusPromotions
34
99
  end
35
100
 
36
101
  if preferred_currency.casecmp(order.currency).zero?
37
- currency_exponent = ::Money::Currency.find(preferred_currency).exponent
38
- (object.amount * (percent || preferred_base_percent) / 100).round(currency_exponent)
102
+ round_to_currency(object.amount * (percent || preferred_base_percent) / 100, preferred_currency)
39
103
  else
40
- 0
104
+ Spree::ZERO
41
105
  end
42
106
  end
43
107
  alias_method :compute_shipment, :compute_item
@@ -45,20 +109,27 @@ module SolidusPromotions
45
109
 
46
110
  private
47
111
 
48
- def cast_to_d(value)
49
- value.to_s.to_d
112
+ # Transforms preferred_tiers keys and values to BigDecimal for consistent calculations.
113
+ #
114
+ # Converts all tier threshold keys and percentage values from strings or other numeric
115
+ # types to BigDecimal to ensure precision in monetary calculations.
116
+ def transform_preferred_tiers
117
+ preferred_tiers.transform_keys! { |key| key.to_s.to_d }
118
+ preferred_tiers.transform_values! { |value| value.to_s.to_d }
50
119
  end
51
120
 
121
+ # Validates that preferred_tiers is properly formatted with valid thresholds and percentages.
122
+ #
123
+ # Ensures:
124
+ # - Tiers is a hash
125
+ # - All keys (thresholds) are positive numbers
126
+ # - All values (percentages) are between 0 and 100
52
127
  def preferred_tiers_content
53
- if preferred_tiers.is_a? Hash
54
- unless preferred_tiers.keys.all? { |key| key.is_a?(Numeric) && key > 0 }
55
- errors.add(:base, :keys_should_be_positive_number)
56
- end
57
- unless preferred_tiers.values.all? { |key| key.is_a?(Numeric) && key >= 0 && key <= 100 }
58
- errors.add(:base, :values_should_be_percent)
59
- end
60
- else
61
- errors.add(:preferred_tiers, :should_be_hash)
128
+ unless preferred_tiers.keys.all? { |key| key.is_a?(Numeric) && key > 0 }
129
+ errors.add(:base, :keys_should_be_positive_number)
130
+ end
131
+ unless preferred_tiers.values.all? { |key| key.is_a?(Numeric) && key >= 0 && key <= 100 }
132
+ errors.add(:base, :values_should_be_percent)
62
133
  end
63
134
  end
64
135
  end