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,34 +4,119 @@ require_dependency "spree/calculator"
4
4
 
5
5
  module SolidusPromotions
6
6
  module Calculators
7
- class TieredPercentOnEligibleItemQuantity < SolidusPromotions::Calculators::TieredPercent
8
- preference :tiers, :hash, default: { 10 => 5 }
9
-
10
- before_validation do
11
- # Convert tier values to decimals. Strings don't do us much good.
12
- if preferred_tiers.is_a?(Hash)
13
- self.preferred_tiers = preferred_tiers.map do |key, value|
14
- [key.to_i, cast_to_d(value.to_s)]
15
- end.to_h
16
- end
17
- end
7
+ # A calculator that applies tiered percentage discounts based on the total quantity of eligible items.
8
+ #
9
+ # This calculator defines discount tiers based on the combined quantity of all eligible line items
10
+ # in an order (not their monetary value). Each tier specifies a minimum quantity threshold and the
11
+ # corresponding percentage discount to apply. The calculator selects the highest tier that the
12
+ # order's eligible item quantity meets or exceeds.
13
+ #
14
+ # The tier thresholds are evaluated against the total quantity of eligible line items, but the
15
+ # percentage discount is applied to each individual item's discountable amount. This makes it
16
+ # ideal for "buy more, save more" promotions based on item count rather than order value.
17
+ #
18
+ # If the total eligible quantity doesn't meet any tier threshold, the base percentage is used.
19
+ # The discount is only applied if the currency matches the preferred currency.
20
+ #
21
+ # @example Use case: Bulk quantity discounts
22
+ # # Buy 10+ items get 5% off, 25+ get 10% off, 50+ get 15% off
23
+ # calculator = TieredPercentOnEligibleItemQuantity.new(
24
+ # preferred_base_percent: 0,
25
+ # preferred_tiers: {
26
+ # 10 => 5, # 5% off when total eligible quantity >= 10
27
+ # 25 => 10, # 10% off when total eligible quantity >= 25
28
+ # 50 => 15 # 15% off when total eligible quantity >= 50
29
+ # },
30
+ # preferred_currency: 'USD'
31
+ # )
32
+ #
33
+ # @example Use case: Multi-item bundle promotions
34
+ # # Encourage buying multiple items from a category
35
+ # calculator = TieredPercentOnEligibleItemQuantity.new(
36
+ # preferred_base_percent: 0,
37
+ # preferred_tiers: {
38
+ # 3 => 10, # 10% off when buying 3+ eligible items
39
+ # 5 => 15, # 15% off when buying 5+ eligible items
40
+ # 10 => 20 # 20% off when buying 10+ eligible items
41
+ # },
42
+ # preferred_currency: 'USD'
43
+ # )
44
+ class TieredPercentOnEligibleItemQuantity < Spree::Calculator
45
+ include PromotionCalculator
46
+
47
+ preference :base_percent, :decimal, default: Spree::ZERO
48
+ preference :tiers, :hash, default: {10 => 5}
49
+ preference :currency, :string, default: -> { Spree::Config[:currency] }
18
50
 
19
- def compute_line_item(line_item)
20
- order = line_item.order
51
+ before_validation :transform_preferred_tiers
52
+
53
+ # Computes the tiered percentage discount for an item based on total eligible item quantity.
54
+ #
55
+ # Evaluates the total quantity of all eligible line items in the order against all defined
56
+ # tiers and selects the highest tier threshold that is met or exceeded. Returns a percentage
57
+ # of the item's discountable amount based on the matching tier, or the base percentage if no
58
+ # tier threshold is met. Returns 0 if the currency doesn't match.
59
+ #
60
+ # @param item [Object] The object to calculate the discount for (e.g., LineItem, Shipment, ShippingRate)
61
+ #
62
+ # @return [BigDecimal] The percentage-based discount amount, rounded to currency precision
63
+ #
64
+ # @example Computing discount with tier matching
65
+ # calculator = TieredPercentOnEligibleItemQuantity.new(
66
+ # preferred_base_percent: 0,
67
+ # preferred_tiers: { 10 => 10, 25 => 15 }
68
+ # )
69
+ # # Order has 3 eligible line items with quantities: 5, 6, 4 (total: 15)
70
+ # line_item.discountable_amount # => 50.00
71
+ # calculator.compute_item(line_item) # => 5.00 (10% of $50, matches quantity tier of 10)
72
+ #
73
+ # @example Computing discount below all tiers
74
+ # calculator = TieredPercentOnEligibleItemQuantity.new(
75
+ # preferred_base_percent: 5,
76
+ # preferred_tiers: { 10 => 10, 25 => 15 }
77
+ # )
78
+ # # Order has 2 eligible line items with quantities: 3, 4 (total: 7)
79
+ # line_item.discountable_amount # => 30.00
80
+ # calculator.compute_item(line_item) # => 1.50 (5% base percent of $30)
81
+ #
82
+ # @example Computing discount with currency mismatch
83
+ # calculator = TieredPercentOnEligibleItemQuantity.new(
84
+ # preferred_currency: 'USD',
85
+ # preferred_tiers: { 10 => 10 }
86
+ # )
87
+ # order.currency # => 'EUR'
88
+ # calculator.compute_item(line_item) # => 0
89
+ def compute_item(item)
90
+ order = item.order
21
91
 
22
92
  _base, percent = preferred_tiers.sort.reverse.detect do |value, _|
23
93
  eligible_line_items_quantity_total(order) >= value
24
94
  end
25
95
  if preferred_currency.casecmp(order.currency).zero?
26
- currency_exponent = ::Money::Currency.find(preferred_currency).exponent
27
- (line_item.discountable_amount * (percent || preferred_base_percent) / 100).round(currency_exponent)
96
+ round_to_currency(item.discountable_amount * (percent || preferred_base_percent) / 100, preferred_currency)
28
97
  else
29
- 0
98
+ Spree::ZERO
30
99
  end
31
100
  end
101
+ alias_method :compute_shipment, :compute_item
102
+ alias_method :compute_shipping_rate, :compute_item
103
+ alias_method :compute_line_item, :compute_item
32
104
 
33
105
  private
34
106
 
107
+ # Transforms preferred_tiers keys to integers and values to BigDecimal.
108
+ #
109
+ # Converts tier threshold keys (item quantities) to integers and percentage values
110
+ # to BigDecimal for consistent calculations.
111
+ def transform_preferred_tiers
112
+ preferred_tiers.transform_keys!(&:to_i)
113
+ preferred_tiers.transform_values! { |value| value.to_s.to_d }
114
+ end
115
+
116
+ # Calculates the total quantity of all eligible line items in the order.
117
+ #
118
+ # @param order [Spree::Order] The order to calculate eligible quantity for
119
+ # @return [Integer] The sum of quantities for all applicable line items
35
120
  def eligible_line_items_quantity_total(order)
36
121
  calculable.applicable_line_items(order).sum(&:quantity)
37
122
  end
@@ -1,6 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidusPromotions
4
+ # Base class for all promotion conditions.
5
+ #
6
+ # Conditions determine whether a promotion is eligible to be applied to a specific
7
+ # promotable object (such as an order or line item). Each condition subclass implements
8
+ # the eligibility logic and specifies what type of objects it can be applied to.
9
+ #
10
+ # Conditions work at different levels:
11
+ # - Order-level conditions (include OrderLevelCondition): Check entire orders
12
+ # - Line item-level conditions (include LineItemLevelCondition): Check individual line items
13
+ # - Hybrid conditions (include LineItemApplicableOrderLevelCondition): Check orders but can also
14
+ # filter which line items are eligible
15
+ #
16
+ # @abstract Subclass and override {#applicable?} and {#eligible?} to implement
17
+ # a custom condition.
18
+ #
19
+ # @example Creating an order-level condition
20
+ # class MinimumPurchaseCondition < Condition
21
+ # include OrderLevelCondition
22
+ #
23
+ # preference :minimum_amount, :decimal, default: 50.00
24
+ #
25
+ # def eligible?(order, _options = {})
26
+ # if order.item_total < preferred_minimum_amount
27
+ # eligibility_errors.add(:base, "Order total too low")
28
+ # end
29
+ # eligibility_errors.empty?
30
+ # end
31
+ # end
32
+ #
33
+ # @example Creating a line item-level condition
34
+ # class SpecificProductCondition < Condition
35
+ # include LineItemLevelCondition
36
+ #
37
+ # preference :product_id, :integer
38
+ #
39
+ # def eligible?(line_item, _options = {})
40
+ # if line_item.product_id != preferred_product_id
41
+ # eligibility_errors.add(:base, "Wrong product")
42
+ # end
43
+ # eligibility_errors.empty?
44
+ # end
45
+ # end
4
46
  class Condition < Spree::Base
5
47
  include Spree::Preferences::Persistable
6
48
 
@@ -12,46 +54,183 @@ module SolidusPromotions
12
54
  validate :unique_per_benefit, on: :create
13
55
  validate :possible_condition_for_benefit, if: -> { benefit.present? }
14
56
 
57
+ class << self
58
+ # Returns the subset of conditions that can compute eligibility instances of the provided array of classes.
59
+ #
60
+ # @example SolidusPromotions::Condition.applicable_to([Spree::Order, Spree::LineItem])
61
+ #
62
+ # @param [Array<Class>] An array of classes to compute eligibility for
63
+ # @return [Array<SolidusPromotions::Condition>] Conditions that can compute eligibility for the provided classes.
64
+ def applicable_to(classes)
65
+ SolidusPromotions.config.conditions.select do |condition_class|
66
+ (condition_class.instance_methods & classes.map { |k| eligible_method_for(k) }).any?
67
+ end
68
+ end
69
+
70
+ # Generates the eligibility method name for a promotable
71
+ #
72
+ # @return [Symbol] the method name
73
+ def eligible_method_for(promotable_class)
74
+ :"#{promotable_class.name.demodulize.underscore}_eligible?"
75
+ end
76
+ end
77
+
78
+ # Returns relations that should be preloaded for this condition.
79
+ #
80
+ # Override this method in subclasses to specify associations that should be eager loaded
81
+ # to avoid N+1 queries when evaluating conditions.
82
+ #
83
+ # @return [Array<Symbol>] An array of association names to preload
84
+ #
85
+ # @example Preloading products association
86
+ # def preload_relations
87
+ # [:products]
88
+ # end
15
89
  def preload_relations
16
90
  []
17
91
  end
18
92
 
19
- def applicable?(_promotable)
20
- raise NotImplementedError, "applicable? should be implemented in a sub-class of SolidusPromotions::Rule"
93
+ # Determines if this condition can be applied to a given promotable object.
94
+ #
95
+ # @param _promotable [Object] The object to check (e.g., Spree::Order, Spree::LineItem)
96
+ #
97
+ # @return [Boolean] true if this condition applies to the promotable type
98
+ #
99
+ # @example Condition applicability
100
+ # condition.applicable?(order) # => true
101
+ # condition.applicable?(line_item) # => false
102
+ def applicable?(promotable)
103
+ respond_to?(eligible_method_for(promotable))
21
104
  end
22
105
 
23
- def eligible?(_promotable, _options = {})
24
- raise NotImplementedError, "eligible? should be implemented in a sub-class of SolidusPromotions::Rule"
106
+ # Determines if the promotable object meets this condition's eligibility requirements.
107
+ #
108
+ # This typically dispatches to a specific eligibility method defined on a subclass, such as
109
+ # `#order_eligible?` or `line_item_eligible?`.
110
+ #
111
+ # @param _promotable [Object] The object to evaluate (e.g., Spree::Order, Spree::LineItem)
112
+ # @param _options [Hash] Additional options for eligibility checking
113
+ #
114
+ # @return [Boolean] true if the promotable meets the condition, false otherwise
115
+ #
116
+ # @see #eligibility_errors
117
+ def eligible?(promotable, ...)
118
+ if applicable?(promotable)
119
+ send(eligible_method_for(promotable), promotable, ...)
120
+ else
121
+ raise NotImplementedError, "Please implement #{eligible_method_for(promotable)} in your condition"
122
+ end
25
123
  end
26
124
 
27
- def level
28
- raise NotImplementedError, "level should be implemented in a sub-class of SolidusPromotions::Rule"
125
+ def self.inherited(klass)
126
+ def klass.method_added(method_added)
127
+ if method_added == :eligible?
128
+ Spree.deprecator.warn <<~MSG
129
+ Please refactor `#{name}`. You're defining `eligible?`. Instead, define method for each type of promotable
130
+ that your condition can be applied to. For example:
131
+ ```
132
+ class MyCondition < SolidusPromotions::Condition
133
+ def applicable?(promotable)
134
+ promotable.is_a?(Spree::Order)
135
+ end
136
+
137
+ def eligible?(order)
138
+ order.total > 20
139
+ end
140
+ ```
141
+ can now become
142
+ ```
143
+ class MyCondition < SolidusPromotions::Condition
144
+ def order_eligible?(order)
145
+ order.total > 20
146
+ end
147
+ end
148
+ ```
149
+ MSG
150
+ end
151
+ super
152
+ end
153
+ super
29
154
  end
30
155
 
156
+ def level
157
+ raise NotImplementedError, "level should be implemented in a sub-class of SolidusPromotions::Condition"
158
+ end
159
+ deprecate :level, deprecator: Spree.deprecator
160
+
161
+ # Returns an errors object for tracking eligibility failures.
162
+ #
163
+ # When {#eligible?} determines that a promotable doesn't meet the condition,
164
+ # it should add descriptive errors to this object. These errors are used to
165
+ # provide feedback about why a promotion isn't being applied.
166
+ #
167
+ # @return [ActiveModel::Errors] An errors collection for this condition
168
+ #
169
+ # @example Adding an eligibility error
170
+ # def eligible?(order, _options = {})
171
+ # if order.item_total < 50
172
+ # eligibility_errors.add(:base, "Minimum order is $50", error_code: :item_total_too_low)
173
+ # end
174
+ # eligibility_errors.empty?
175
+ # end
31
176
  def eligibility_errors
32
177
  @eligibility_errors ||= ActiveModel::Errors.new(self)
33
178
  end
34
179
 
180
+ # Returns the partial path for rendering this condition in the admin interface.
181
+ #
182
+ # @return [String] The path to the admin form partial for this condition
183
+ #
184
+ # @example
185
+ # # For SolidusPromotions::Conditions::ItemTotal
186
+ # # => "solidus_promotions/admin/condition_fields/item_total"
35
187
  def to_partial_path
36
188
  "solidus_promotions/admin/condition_fields/#{model_name.element}"
37
189
  end
38
190
 
191
+ # Determines if this condition can be updated in the admin interface.
192
+ #
193
+ # A condition is considered updateable if it has any preferences that can be configured.
194
+ #
195
+ # @return [Boolean] true if the condition has configurable preferences
39
196
  def updateable?
40
197
  preferences.any?
41
198
  end
42
199
 
43
200
  private
44
201
 
202
+ def eligible_method_for(promotable)
203
+ self.class.eligible_method_for(promotable.class)
204
+ end
205
+
206
+ # Validates that only one instance of this condition type exists per benefit.
207
+ #
208
+ # Prevents duplicate conditions of the same type from being added to a single benefit.
45
209
  def unique_per_benefit
46
210
  return unless self.class.exists?(benefit_id: benefit_id, type: self.class.name)
47
211
 
48
212
  errors.add(:benefit, :already_contains_condition_type)
49
213
  end
50
214
 
215
+ # Validates that this condition type is allowed for the associated benefit.
216
+ #
217
+ # Checks the benefit's {Benefit#applicable_conditions} to ensure this condition
218
+ # type is compatible.
51
219
  def possible_condition_for_benefit
52
- benefit.possible_conditions.include?(self.class) || errors.add(:type, :invalid_condition_type)
220
+ benefit.class.applicable_conditions.include?(self.class) || errors.add(:type, :invalid_condition_type)
53
221
  end
54
222
 
223
+ # Generates a translated eligibility error message.
224
+ #
225
+ # Looks up the error message in the I18n translations under the condition's scope.
226
+ #
227
+ # @param key [Symbol] The I18n key for the error message
228
+ # @param options [Hash] Interpolation options for the message
229
+ #
230
+ # @return [String] The translated error message
231
+ #
232
+ # @example
233
+ # eligibility_error_message(:item_total_too_low, minimum: "$50")
55
234
  def eligibility_error_message(key, options = {})
56
235
  I18n.t(key, scope: [:solidus_promotions, :eligibility_errors, self.class.name.underscore], **options)
57
236
  end
@@ -3,10 +3,12 @@
3
3
  module SolidusPromotions
4
4
  module Conditions
5
5
  class FirstOrder < Condition
6
+ # TODO: Remove in Solidus 5
6
7
  include OrderLevelCondition
8
+
7
9
  attr_reader :user, :email
8
10
 
9
- def eligible?(order, options = {})
11
+ def order_eligible?(order, options = {})
10
12
  @user = order.try(:user) || options[:user]
11
13
  @email = order.email
12
14
 
@@ -3,16 +3,17 @@
3
3
  module SolidusPromotions
4
4
  module Conditions
5
5
  class FirstRepeatPurchaseSince < Condition
6
+ # TODO: Remove in Solidus 5
6
7
  include OrderLevelCondition
7
8
 
8
9
  preference :days_ago, :integer, default: 365
9
- validates :preferred_days_ago, numericality: { only_integer: true, greater_than: 0 }
10
+ validates :preferred_days_ago, numericality: {only_integer: true, greater_than: 0}
10
11
 
11
12
  # This is never eligible if the order does not have a user, and that user does not have any previous completed orders.
12
13
  #
13
14
  # This is eligible if the user's most recently completed order is more than the preferred days ago
14
15
  # @param order [Spree::Order]
15
- def eligible?(order, _options = {})
16
+ def order_eligible?(order, _options = {})
16
17
  return false unless order.user
17
18
 
18
19
  last_order = last_completed_order(order.user)
@@ -8,6 +8,7 @@ module SolidusPromotions
8
8
  # To add extra operators please override `self.operators_map` or any other helper method.
9
9
  # To customize the error message you can also override `ineligible_message`.
10
10
  class ItemTotal < Condition
11
+ # TODO: Remove in Solidus 5
11
12
  include OrderLevelCondition
12
13
 
13
14
  preference :amount, :decimal, default: 100.00
@@ -28,7 +29,7 @@ module SolidusPromotions
28
29
  end
29
30
  end
30
31
 
31
- def eligible?(order, _options = {})
32
+ def order_eligible?(order, _options = {})
32
33
  return false unless order.currency == preferred_currency
33
34
 
34
35
  unless total_for_order(order).send(operator, threshold)
@@ -3,26 +3,18 @@
3
3
  module SolidusPromotions
4
4
  module Conditions
5
5
  class LineItemOptionValue < Condition
6
+ # TODO: Remove in Solidus 5
6
7
  include LineItemLevelCondition
7
8
 
8
- preference :eligible_values, :hash
9
+ include OptionValueCondition
9
10
 
10
- def eligible?(line_item, _options = {})
11
+ def line_item_eligible?(line_item, _options = {})
11
12
  pid = line_item.product.id
12
- ovids = line_item.variant.option_values.pluck(:id)
13
+ ovids = line_item.variant.option_value_ids
13
14
 
14
15
  product_ids.include?(pid) && (value_ids(pid) & ovids).present?
15
16
  end
16
17
 
17
- def preferred_eligible_values
18
- values = preferences[:eligible_values] || {}
19
- values.keys.map(&:to_i).zip(
20
- values.values.map do |value|
21
- (value.is_a?(Array) ? value : value.split(",")).map(&:to_i)
22
- end
23
- ).to_h
24
- end
25
-
26
18
  private
27
19
 
28
20
  def product_ids
@@ -4,26 +4,16 @@ module SolidusPromotions
4
4
  module Conditions
5
5
  # A condition to apply a promotion only to line items with or without a chosen product
6
6
  class LineItemProduct < Condition
7
+ # TODO: Remove in Solidus 5
7
8
  include LineItemLevelCondition
8
9
 
9
- MATCH_POLICIES = %w[include exclude].freeze
10
+ include ProductCondition
10
11
 
11
- has_many :condition_products,
12
- dependent: :destroy,
13
- foreign_key: :condition_id,
14
- class_name: "SolidusPromotions::ConditionProduct",
15
- inverse_of: :condition
16
- has_many :products,
17
- class_name: "Spree::Product",
18
- through: :condition_products
12
+ MATCH_POLICIES = %w[include exclude].freeze
19
13
 
20
14
  preference :match_policy, :string, default: MATCH_POLICIES.first
21
15
 
22
- def preload_relations
23
- [:products]
24
- end
25
-
26
- def eligible?(line_item, _options = {})
16
+ def line_item_eligible?(line_item, _options = {})
27
17
  order_includes_product = product_ids.include?(line_item.variant.product_id)
28
18
  success = inverse? ? !order_includes_product : order_includes_product
29
19
 
@@ -39,14 +29,6 @@ module SolidusPromotions
39
29
  success
40
30
  end
41
31
 
42
- def product_ids_string
43
- product_ids.join(",")
44
- end
45
-
46
- def product_ids_string=(product_ids)
47
- self.product_ids = product_ids.to_s.split(",").map(&:strip)
48
- end
49
-
50
32
  private
51
33
 
52
34
  def inverse?
@@ -3,60 +3,29 @@
3
3
  module SolidusPromotions
4
4
  module Conditions
5
5
  class LineItemTaxon < Condition
6
+ # TODO: Remove in Solidus 5
6
7
  include LineItemLevelCondition
7
8
 
8
- has_many :condition_taxons,
9
- class_name: "SolidusPromotions::ConditionTaxon",
10
- foreign_key: :condition_id,
11
- dependent: :destroy,
12
- inverse_of: :condition
13
- has_many :taxons, through: :condition_taxons, class_name: "Spree::Taxon"
9
+ include TaxonCondition
14
10
 
15
11
  MATCH_POLICIES = %w[include exclude].freeze
16
12
 
17
- validates :preferred_match_policy, inclusion: { in: MATCH_POLICIES }
13
+ validates :preferred_match_policy, inclusion: {in: MATCH_POLICIES}
18
14
 
19
15
  preference :match_policy, :string, default: MATCH_POLICIES.first
20
16
 
21
- def preload_relations
22
- [:taxons]
23
- end
24
-
25
- def eligible?(line_item, _options = {})
26
- found = Spree::Classification.where(
27
- product_id: line_item.variant.product_id,
28
- taxon_id: condition_taxon_ids_with_children
29
- ).exists?
17
+ def line_item_eligible?(line_item, _options = {})
18
+ line_item_taxon_ids = line_item.variant.product.classifications.map(&:taxon_id)
30
19
 
31
20
  case preferred_match_policy
32
21
  when "include"
33
- found
22
+ taxon_ids_with_children.any? { |taxon_and_descendant_ids| (line_item_taxon_ids & taxon_and_descendant_ids).any? }
34
23
  when "exclude"
35
- !found
24
+ taxon_ids_with_children.none? { |taxon_and_descendant_ids| (line_item_taxon_ids & taxon_and_descendant_ids).any? }
36
25
  else
37
26
  raise "unexpected match policy: #{preferred_match_policy.inspect}"
38
27
  end
39
28
  end
40
-
41
- def taxon_ids_string
42
- taxons.pluck(:id).join(",")
43
- end
44
-
45
- def taxon_ids_string=(taxon_ids)
46
- taxon_ids = taxon_ids.to_s.split(",").map(&:strip)
47
- self.taxons = Spree::Taxon.find(taxon_ids)
48
- end
49
-
50
- def updateable?
51
- true
52
- end
53
-
54
- private
55
-
56
- # ids of taxons conditions and taxons conditions children
57
- def condition_taxon_ids_with_children
58
- taxons.flat_map { |taxon| taxon.self_and_descendants.ids }.uniq
59
- end
60
29
  end
61
30
  end
62
31
  end
@@ -10,9 +10,10 @@ module SolidusPromotions
10
10
  # it to a simple quantity check across the entire order which would be
11
11
  # better served by an item total condition.
12
12
  class MinimumQuantity < Condition
13
+ # TODO: Remove in Solidus 5
13
14
  include OrderLevelCondition
14
15
 
15
- validates :preferred_minimum_quantity, numericality: { only_integer: true, greater_than: 0 }
16
+ validates :preferred_minimum_quantity, numericality: {only_integer: true, greater_than: 0}
16
17
 
17
18
  preference :minimum_quantity, :integer, default: 1
18
19
 
@@ -26,7 +27,7 @@ module SolidusPromotions
26
27
  #
27
28
  # @param order [Spree::Order] the order we want to check eligibility on
28
29
  # @return [Boolean] true if promotion is eligible, false otherwise
29
- def eligible?(order)
30
+ def order_eligible?(order, _options = {})
30
31
  if benefit.applicable_line_items(order).sum(&:quantity) < preferred_minimum_quantity
31
32
  eligibility_errors.add(
32
33
  :base,
@@ -3,18 +3,19 @@
3
3
  module SolidusPromotions
4
4
  module Conditions
5
5
  class NthOrder < Condition
6
+ # TODO: Remove in Solidus 5
6
7
  include OrderLevelCondition
7
8
 
8
9
  preference :nth_order, :integer, default: 2
9
10
  # It does not make sense to have this apply to the first order using preferred_nth_order == 1
10
11
  # Instead we could use the first_order condition
11
- validates :preferred_nth_order, numericality: { only_integer: true, greater_than: 1 }
12
+ validates :preferred_nth_order, numericality: {only_integer: true, greater_than: 1}
12
13
 
13
14
  # This is never eligible if the order does not have a user, and that user does not have any previous completed orders.
14
15
  #
15
16
  # Use the first order condition if you want a promotion to be applied to the first order for a user.
16
17
  # @param order [Spree::Order]
17
- def eligible?(order, _options = {})
18
+ def order_eligible?(order, _options = {})
18
19
  return false unless order.user
19
20
 
20
21
  nth_order?(order)
@@ -3,9 +3,10 @@
3
3
  module SolidusPromotions
4
4
  module Conditions
5
5
  class OneUsePerUser < Condition
6
+ # TODO: Remove in Solidus 5
6
7
  include OrderLevelCondition
7
8
 
8
- def eligible?(order, _options = {})
9
+ def order_eligible?(order, _options = {})
9
10
  if order.user.present?
10
11
  if promotion.used_by?(order.user, [order])
11
12
  eligibility_errors.add(
@@ -5,23 +5,18 @@ module SolidusPromotions
5
5
  class OptionValue < Condition
6
6
  include LineItemApplicableOrderLevelCondition
7
7
 
8
- preference :eligible_values, :hash
8
+ include OptionValueCondition
9
9
 
10
- def order_eligible?(order)
11
- order.line_items.any? { |item| line_item_eligible?(item) }
10
+ def order_eligible?(order, _options = {})
11
+ OrderOptionValue.new(preferred_eligible_values: preferred_eligible_values).eligible?(order)
12
12
  end
13
13
 
14
- def line_item_eligible?(line_item)
14
+ def line_item_eligible?(line_item, _options = {})
15
15
  LineItemOptionValue.new(preferred_eligible_values: preferred_eligible_values).eligible?(line_item)
16
16
  end
17
17
 
18
- def preferred_eligible_values
19
- values = preferences[:eligible_values] || {}
20
- values.keys.map(&:to_i).zip(
21
- values.values.map do |value|
22
- (value.is_a?(Array) ? value : value.split(",")).map(&:to_i)
23
- end
24
- ).to_h
18
+ def price_eligible?(price, _options = {})
19
+ PriceOptionValue.new(preferred_eligible_values: preferred_eligible_values).eligible?(price)
25
20
  end
26
21
  end
27
22
  end