solidus_promotions 4.6.4 → 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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/MIGRATING.md +10 -4
  3. data/README.md +7 -0
  4. data/app/jobs/solidus_promotions/promotion_code_batch_job.rb +1 -1
  5. data/app/models/concerns/solidus_promotions/adjustment_discounts.rb +20 -0
  6. data/app/models/concerns/solidus_promotions/benefits/line_item_benefit.rb +5 -0
  7. data/app/models/concerns/solidus_promotions/benefits/order_benefit.rb +5 -0
  8. data/app/models/concerns/solidus_promotions/benefits/shipment_benefit.rb +5 -0
  9. data/app/models/concerns/solidus_promotions/calculators/promotion_calculator.rb +7 -0
  10. data/app/models/concerns/solidus_promotions/conditions/line_item_applicable_order_level_condition.rb +17 -5
  11. data/app/models/concerns/solidus_promotions/conditions/line_item_level_condition.rb +15 -2
  12. data/app/models/concerns/solidus_promotions/conditions/option_value_condition.rb +21 -0
  13. data/app/models/concerns/solidus_promotions/conditions/order_level_condition.rb +15 -2
  14. data/app/models/concerns/solidus_promotions/conditions/product_condition.rb +28 -0
  15. data/app/models/concerns/solidus_promotions/conditions/shipment_level_condition.rb +15 -2
  16. data/app/models/concerns/solidus_promotions/conditions/taxon_condition.rb +77 -0
  17. data/app/models/concerns/solidus_promotions/coupon_code_normalizer.rb +37 -0
  18. data/app/models/concerns/solidus_promotions/discountable_amount.rb +3 -4
  19. data/app/models/concerns/solidus_promotions/discounted_amount.rb +54 -0
  20. data/app/models/solidus_promotions/benefit.rb +252 -37
  21. data/app/models/solidus_promotions/benefits/adjust_line_item.rb +28 -3
  22. data/app/models/solidus_promotions/benefits/adjust_line_item_quantity_groups.rb +1 -0
  23. data/app/models/solidus_promotions/benefits/adjust_shipment.rb +45 -3
  24. data/app/models/solidus_promotions/benefits/advertise_price.rb +28 -0
  25. data/app/models/solidus_promotions/benefits/create_discounted_item.rb +30 -7
  26. data/app/models/solidus_promotions/calculators/distributed_amount.rb +34 -8
  27. data/app/models/solidus_promotions/calculators/flat_rate.rb +52 -6
  28. data/app/models/solidus_promotions/calculators/flexi_rate.rb +69 -6
  29. data/app/models/solidus_promotions/calculators/percent.rb +40 -4
  30. data/app/models/solidus_promotions/calculators/percent_with_cap.rb +44 -3
  31. data/app/models/solidus_promotions/calculators/tiered_flat_rate.rb +81 -19
  32. data/app/models/solidus_promotions/calculators/tiered_percent.rb +96 -25
  33. data/app/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity.rb +101 -16
  34. data/app/models/solidus_promotions/condition.rb +186 -7
  35. data/app/models/solidus_promotions/conditions/first_order.rb +3 -1
  36. data/app/models/solidus_promotions/conditions/first_repeat_purchase_since.rb +3 -2
  37. data/app/models/solidus_promotions/conditions/item_total.rb +2 -1
  38. data/app/models/solidus_promotions/conditions/line_item_option_value.rb +4 -12
  39. data/app/models/solidus_promotions/conditions/line_item_product.rb +4 -22
  40. data/app/models/solidus_promotions/conditions/line_item_taxon.rb +7 -38
  41. data/app/models/solidus_promotions/conditions/minimum_quantity.rb +3 -2
  42. data/app/models/solidus_promotions/conditions/nth_order.rb +3 -2
  43. data/app/models/solidus_promotions/conditions/one_use_per_user.rb +2 -1
  44. data/app/models/solidus_promotions/conditions/option_value.rb +6 -11
  45. data/app/models/solidus_promotions/conditions/order_option_value.rb +19 -0
  46. data/app/models/solidus_promotions/conditions/order_product.rb +62 -0
  47. data/app/models/solidus_promotions/conditions/order_taxon.rb +60 -0
  48. data/app/models/solidus_promotions/conditions/price_option_value.rb +26 -0
  49. data/app/models/solidus_promotions/conditions/price_product.rb +36 -0
  50. data/app/models/solidus_promotions/conditions/price_taxon.rb +28 -0
  51. data/app/models/solidus_promotions/conditions/product.rb +17 -59
  52. data/app/models/solidus_promotions/conditions/shipping_method.rb +3 -5
  53. data/app/models/solidus_promotions/conditions/store.rb +2 -1
  54. data/app/models/solidus_promotions/conditions/taxon.rb +24 -73
  55. data/app/models/solidus_promotions/conditions/user.rb +2 -1
  56. data/app/models/solidus_promotions/conditions/user_logged_in.rb +1 -3
  57. data/app/models/solidus_promotions/conditions/user_role.rb +1 -3
  58. data/app/models/solidus_promotions/distributed_amounts_handler.rb +2 -6
  59. data/app/models/solidus_promotions/eligibility_results.rb +1 -0
  60. data/app/models/solidus_promotions/item_discount.rb +1 -0
  61. data/app/models/solidus_promotions/order_adjuster/discount_order.rb +29 -35
  62. data/app/models/solidus_promotions/order_adjuster/recalculate_promo_totals.rb +45 -0
  63. data/app/models/solidus_promotions/order_adjuster/set_discounts_to_zero.rb +33 -0
  64. data/app/models/solidus_promotions/order_adjuster.rb +4 -14
  65. data/app/models/solidus_promotions/order_promotion.rb +1 -0
  66. data/app/models/solidus_promotions/product_advertiser.rb +57 -0
  67. data/app/models/solidus_promotions/promotion.rb +13 -12
  68. data/app/models/solidus_promotions/promotion_code/batch_builder.rb +1 -1
  69. data/app/models/solidus_promotions/promotion_code.rb +4 -4
  70. data/app/models/solidus_promotions/promotion_code_batch.rb +1 -1
  71. data/app/models/solidus_promotions/promotion_handler/coupon.rb +1 -1
  72. data/app/models/solidus_promotions/promotion_handler/page.rb +1 -1
  73. data/app/models/solidus_promotions/promotion_lane.rb +48 -0
  74. data/app/models/solidus_promotions/shipping_rate_discount.rb +3 -0
  75. data/app/patches/models/solidus_promotions/line_item_patch.rb +2 -0
  76. data/app/patches/models/solidus_promotions/order_patch.rb +8 -0
  77. data/app/patches/models/solidus_promotions/order_recalculator_patch.rb +3 -1
  78. data/app/patches/models/solidus_promotions/price_patch.rb +31 -0
  79. data/app/patches/models/solidus_promotions/shipment_patch.rb +2 -0
  80. data/app/patches/models/solidus_promotions/shipping_rate_patch.rb +15 -0
  81. data/config/locales/en.yml +46 -11
  82. data/config/routes.rb +1 -1
  83. data/db/migrate/20230703101637_create_promotions.rb +2 -2
  84. data/db/migrate/20230703113625_create_promotion_benefits.rb +3 -3
  85. data/db/migrate/20230703141116_create_promotion_categories.rb +1 -1
  86. data/db/migrate/20230703143943_create_promotion_conditions.rb +1 -1
  87. data/db/migrate/20230704083830_add_condition_join_tables.rb +8 -8
  88. data/db/migrate/20230704102444_create_promotion_codes.rb +1 -1
  89. data/db/migrate/20230704102656_create_promotion_code_batches.rb +1 -1
  90. data/db/migrate/20230705171556_create_order_promotions.rb +3 -3
  91. data/db/migrate/20230725074235_create_shipping_rate_discounts.rb +2 -2
  92. data/db/migrate/20231104135812_add_managed_by_order_benefit_to_line_items.rb +1 -1
  93. data/db/migrate/20251104170913_update_promotion_code_value_collation.rb +38 -0
  94. data/db/migrate/20251104214304_separate_out_order_only_conditions.rb +41 -0
  95. data/eslint.config.mjs +29 -0
  96. data/lib/components/admin/solidus_promotions/promotion_categories/index/component.rb +6 -6
  97. data/lib/components/admin/solidus_promotions/promotions/index/component.rb +5 -5
  98. data/lib/solidus_promotions/configuration.rb +56 -12
  99. data/lib/solidus_promotions/promotion_map.rb +14 -14
  100. data/lib/solidus_promotions/testing_support/shared_examples/option_value_condition.rb +18 -0
  101. data/lib/solidus_promotions/testing_support/shared_examples/product_condition.rb +37 -0
  102. data/lib/solidus_promotions/testing_support/shared_examples/promotion_calculator.rb +11 -0
  103. data/lib/solidus_promotions/testing_support/shared_examples/taxon_condition.rb +37 -0
  104. data/lib/solidus_promotions/testing_support/shared_examples.rb +6 -0
  105. data/lib/views/backend/solidus_promotions/admin/benefit_fields/_advertise_price.html.erb +7 -0
  106. data/lib/views/backend/solidus_promotions/admin/calculator_fields/percent/_fields.html.erb +1 -1
  107. data/lib/views/backend/solidus_promotions/admin/condition_fields/_option_value.html.erb +0 -7
  108. data/lib/views/backend/solidus_promotions/admin/condition_fields/_price_option_value.html.erb +26 -0
  109. data/lib/views/backend/solidus_promotions/admin/condition_fields/_price_product.html.erb +21 -0
  110. data/lib/views/backend/solidus_promotions/admin/condition_fields/_price_taxon.html.erb +17 -0
  111. data/lib/views/backend/solidus_promotions/admin/condition_fields/_product.html.erb +0 -7
  112. data/lib/views/backend/solidus_promotions/admin/condition_fields/_taxon.html.erb +0 -7
  113. data/solidus_promotions.gemspec +1 -1
  114. metadata +37 -6
  115. data/.eslintrc.json +0 -10
  116. data/app/models/solidus_promotions/order_adjuster/persist_discounted_order.rb +0 -79
@@ -1,65 +1,240 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidusPromotions
4
- # Base class for all types of benefit.
4
+ # Base class for all promotion benefits.
5
5
  #
6
- # Benefits perform the necessary tasks when a promotion is activated
7
- # by an event and determined to be eligible.
6
+ # A Benefit is the active part of a promotion: once a promotion becomes
7
+ # eligible for a given promotable (order, line item, or shipment), the benefit
8
+ # determines how much discount to apply and produces the corresponding
9
+ # adjustments.
10
+ #
11
+ # Subclasses specialize the discounting target (orders, line items, or
12
+ # shipments) and usually include one of the following mixins to integrate with
13
+ # Solidus' adjustment system:
14
+ # - SolidusPromotions::Benefits::AdjustLineItem
15
+ # - SolidusPromotions::Benefits::AdjustShipment
16
+ # - SolidusPromotions::Benefits::CreateDiscountedItem
17
+ #
18
+ # A benefit can discount any object for which {#can_discount?} returns true.
19
+ # Implementors must provide a calculator via Spree::CalculatedAdjustments and
20
+ # may override methods such as {#adjustment_label}.
21
+ #
22
+ # Usage example
23
+ #
24
+ # benefit = SolidusPromotions::Benefits::AdjustLineItem.new(promotion: promo)
25
+ # if benefit.can_discount?(line_item)
26
+ # discount = benefit.discount(line_item)
27
+ # # => #<SolidusPromotions::ItemDiscount ...>
28
+ # end
29
+ #
30
+ # @see SolidusPromotions::Promotion
31
+ # @see Spree::CalculatedAdjustments
8
32
  class Benefit < Spree::Base
9
33
  include Spree::Preferences::Persistable
10
34
  include Spree::CalculatedAdjustments
11
35
  include Spree::AdjustmentSource
36
+
12
37
  before_destroy :remove_adjustments_from_incomplete_orders
13
38
  before_destroy :raise_for_adjustments_for_completed_orders
14
39
 
15
40
  # @!attribute [rw] promotion
16
41
  # The owning promotion.
17
42
  # @return [SolidusPromotions::Promotion]
18
- belongs_to :promotion, -> { with_discarded }, inverse_of: :benefits
43
+ belongs_to :promotion, inverse_of: :benefits
19
44
  # @!attribute [rw] original_promotion_action
20
45
  # Back-reference to the original Solidus (Spree) promotion action, when migrated.
21
46
  # @return [Spree::PromotionAction, nil]
22
47
  belongs_to :original_promotion_action, class_name: "Spree::PromotionAction", optional: true
48
+ # @!attribute [r] adjustments
49
+ # Adjustments created by this benefit.
50
+ # @return [ActiveRecord::Associations::CollectionProxy<Spree::Adjustment>]
23
51
  has_many :adjustments, class_name: "Spree::Adjustment", as: :source, dependent: :restrict_with_error
52
+ # @!attribute [r] shipping_rate_discounts
53
+ # Shipping-rate-level discounts generated by this benefit.
54
+ # @return [ActiveRecord::Associations::CollectionProxy<SolidusPromotions::ShippingRateDiscount>]
24
55
  has_many :shipping_rate_discounts, class_name: "SolidusPromotions::ShippingRateDiscount", inverse_of: :benefit, dependent: :restrict_with_error
56
+ # @!attribute [r] conditions
57
+ # Conditions attached to this benefit.
58
+ # @return [ActiveRecord::Associations::CollectionProxy<SolidusPromotions::Condition>]
25
59
  has_many :conditions, class_name: "SolidusPromotions::Condition", inverse_of: :benefit, dependent: :destroy
26
60
 
61
+ # @!method self.of_type(type)
62
+ # Restricts benefits to the given STI type(s).
63
+ # @param type [String, Symbol, Class, Array<String,Symbol,Class>] a single type or list of types
64
+ # @return [ActiveRecord::Relation<SolidusPromotions::Benefit>]
27
65
  scope :of_type, ->(type) { where(type: Array.wrap(type).map(&:to_s)) }
28
66
 
67
+ # Base set of order-level condition classes available to all benefits.
68
+ #
69
+ # These generic order conditions apply regardless of the concrete benefit
70
+ # type, as every benefit ultimately operates within the context of an order.
71
+ # Concrete benefit subclasses may extend or override this to include
72
+ # additional applicable conditions that are specific to their discount
73
+ # target (e.g., line-item or shipment conditions).
74
+ #
75
+ # @return [Enumerable<Class<SolidusPromotions::Condition>>]
76
+ def self.applicable_conditions
77
+ SolidusPromotions::Condition.applicable_to([Spree::Order])
78
+ end
79
+
80
+ # Returns relations that should be preloaded for this condition.
81
+ #
82
+ # Override this method in subclasses to specify associations that should be eager loaded
83
+ # to avoid N+1 queries when computing discounts or performing automations.
84
+ #
85
+ # @return [Array<Symbol>] An array of association names to preload
29
86
  def preload_relations
30
87
  [:calculator]
31
88
  end
32
89
 
90
+ # Whether this benefit can discount the given object.
91
+ #
92
+ # Subclasses must implement this according to the kinds
93
+ # of objects they are able to discount.
94
+ #
95
+ # @param object [Object] a potential adjustable (order, line item, or shipment)
96
+ # @return [Boolean]
97
+ # @see SolidusPromotions::Benefits::AdjustLineItem,
98
+ # SolidusPromotions::Benefits::AdjustShipment,
99
+ # SolidusPromotions::Benefits::CreateDiscountedItem
33
100
  def can_discount?(object)
34
- raise NotImplementedError, "Please implement the correct interface, or include one of the `SolidusPromotions::Benefits::OrderBenefit`, " \
35
- "`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules"
101
+ respond_to?(discount_method_for(object))
36
102
  end
37
103
 
38
- def discount(adjustable)
39
- amount = compute_amount(adjustable)
40
- return if amount.zero?
41
- ItemDiscount.new(
42
- item: adjustable,
43
- label: adjustment_label(adjustable),
44
- amount: amount,
45
- source: self
46
- )
104
+ # Calculates and returns a discount for the given adjustable object.
105
+ #
106
+ # This method computes the discount amount using the benefit's calculator and returns
107
+ # an ItemDiscount object representing the discount to be applied. If the computed
108
+ # amount is zero, no discount is returned.
109
+ #
110
+ # @param adjustable [Object] The object to calculate the discount for (e.g., LineItem, Shipment, ShippingRate)
111
+ # @param ... [args, kwargs] Additional arguments passed to the calculator's compute method
112
+ #
113
+ # @return [Spree::Adjustment, SolidusPromotions::ShippingRateDiscount, nil] An ItemDiscount object if a discount applies, nil if the amount is zero
114
+ #
115
+ # @example Calculating a discount for a line item
116
+ # benefit.discount(line_item)
117
+ # # => #<Spree::Adjustment, adjustable: line_item, amount: -10.00, ...>
118
+ #
119
+ # @see #compute_amount
120
+ # @see #adjustment_label
121
+ def discount(adjustable, ...)
122
+ if can_discount?(adjustable)
123
+ send(discount_method_for(adjustable), adjustable, ...)
124
+ else
125
+ raise NotImplementedError, "Please implement #{discount_method_for(adjustable)} in your condition"
126
+ end
47
127
  end
48
128
 
49
- # Ensure a negative amount which does not exceed the object's amount
50
- def compute_amount(adjustable)
51
- promotion_amount = calculator.compute(adjustable) || Spree::ZERO
129
+ def self.inherited(klass)
130
+ def klass.method_added(method_added)
131
+ if method_added == :discount
132
+ Spree.deprecator.warn <<~MSG
133
+ Please refactor `#{name}`. You're defining `#discount`. Instead, define a method for each type of discountable
134
+ that your benefit can discount. For example:
135
+ ```
136
+ class MyBenefit < SolidusPromotions::Benefit
137
+ def can_discount?(discountable)
138
+ discountable.is_a?(Spree::LineItem)
139
+ end
140
+
141
+ def discount(order, _options = {})
142
+ amount = compute_amount(line_item, ...)
143
+ return if amount.zero?
144
+
145
+ ItemDiscount.new(
146
+ item: line_item,
147
+ label: adjustment_label(line_item),
148
+ amount: amount,
149
+ source: self
150
+ )
151
+ end
152
+ ```
153
+ can now become
154
+ ```
155
+ class MyBenefit < SolidusPromotions::Benefit
156
+ def discount_line_item(order, ...)
157
+ amount = compute_amount(line_item, ...)
158
+ return if amount.zero?
159
+
160
+ ItemDiscount.new(
161
+ item: line_item,
162
+ label: adjustment_label(line_item),
163
+ amount: amount,
164
+ source: self
165
+ )
166
+ end
167
+ end
168
+ ```
169
+ MSG
170
+ end
171
+ super
172
+ end
173
+ super
174
+ end
175
+
176
+ # Computes the discount amount for the given adjustable.
177
+ #
178
+ # Ensures the returned amount is negative and does not exceed the
179
+ # adjustable's discountable amount.
180
+ #
181
+ # @param adjustable [#discountable_amount] the adjustable to compute for
182
+ # @param ... [args, kwargs] additional arguments forwarded to the calculator
183
+ # @return [BigDecimal] a negative amount suitable for creating an adjustment
184
+ def compute_amount(adjustable, ...)
185
+ promotion_amount = calculator.compute(adjustable, ...) || Spree::ZERO
52
186
  [adjustable.discountable_amount, promotion_amount.abs].min * -1
53
187
  end
54
188
 
55
- def adjustment_label(adjustable)
56
- I18n.t(
57
- "solidus_promotions.adjustment_labels.#{adjustable.class.name.demodulize.underscore}",
58
- promotion: SolidusPromotions::Promotion.model_name.human,
59
- promotion_customer_label: promotion.customer_label
60
- )
189
+ # Builds the localized label for adjustments created by this benefit.
190
+ #
191
+ # This method attempts to use a calculator-specific label method if available,
192
+ # falling back to a localized string key based on the adjustable's class name.
193
+ #
194
+ # ## Calculator Override
195
+ #
196
+ # Calculators can provide custom labels by implementing a method named after the
197
+ # adjustable type. For example, a calculator that discounts line items could
198
+ # implement `line_item_adjustment_label`:
199
+ #
200
+ # @example Custom calculator with adjustment label
201
+ # class MyCalculator < Spree::Calculator
202
+ # def compute(adjustable, *args)
203
+ # # calculation logic
204
+ # end
205
+ #
206
+ # def line_item_adjustment_label(line_item, *args)
207
+ # "Custom discount for #{line_item.product.name}"
208
+ # end
209
+ # end
210
+ #
211
+ # The method name follows the pattern: `{adjustable_type}_adjustment_label`
212
+ # where `{adjustable_type}` is the underscored class name of the adjustable
213
+ # (e.g., `line_item`, `shipment`, `shipping_rate`).
214
+ #
215
+ # If the calculator does not respond to the expected method, the benefit will
216
+ # fall back to using an i18n translation key based on the adjustable's class.
217
+ #
218
+ # @param adjustable [Object] the object being discounted (e.g., Spree::LineItem, Spree::Shipment)
219
+ # @param ... [args, kwargs] additional arguments forwarded to the calculator's label method
220
+ # @return [String] a localized label suitable for display in adjustments
221
+ #
222
+ # @see #adjustment_label_method_for
223
+ def adjustment_label(adjustable, ...)
224
+ if calculator.respond_to?(adjustment_label_method_for(adjustable))
225
+ calculator.send(adjustment_label_method_for(adjustable), adjustable, ...)
226
+ else
227
+ I18n.t(
228
+ "solidus_promotions.adjustment_labels.#{adjustable.class.name.demodulize.underscore}",
229
+ promotion: SolidusPromotions::Promotion.model_name.human,
230
+ promotion_customer_label: promotion.customer_label
231
+ )
232
+ end
61
233
  end
62
234
 
235
+ # Partial path used for admin forms for this benefit type.
236
+ #
237
+ # @return [String]
63
238
  def to_partial_path
64
239
  "solidus_promotions/admin/benefit_fields/#{model_name.element}"
65
240
  end
@@ -68,33 +243,47 @@ module SolidusPromotions
68
243
  raise NotImplementedError, "Please implement the correct interface, or include one of the `SolidusPromotions::Benefits::OrderBenefit`, " \
69
244
  "`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules"
70
245
  end
246
+ deprecate :level, deprecator: Spree.deprecator
71
247
 
248
+ # Returns the set of condition classes that can still be attached to this benefit.
249
+ # Already-persisted conditions are excluded.
250
+ #
251
+ # @return [Set<Class<SolidusPromotions::Condition>>]
72
252
  def available_conditions
73
- possible_conditions - conditions.select(&:persisted?)
253
+ self.class.applicable_conditions - conditions.select(&:persisted?)
74
254
  end
75
255
 
256
+ # Returns the calculators allowed for this benefit type.
257
+ #
258
+ # @return [Array<Class>] calculator classes
76
259
  def available_calculators
77
260
  SolidusPromotions.config.promotion_calculators[self.class] || []
78
261
  end
79
262
 
263
+ # Verifies if the promotable satisfies all applicable conditions of this benefit.
264
+ #
265
+ # When dry_run is true, an {SolidusPromotions::EligibilityResults} entry is
266
+ # recorded for each condition with success/error details; otherwise, the
267
+ # evaluation short-circuits on the first failure.
268
+ #
269
+ # @param promotable [Object] the entity being evaluated (e.g., Spree::Order, Spree::LineItem)
270
+ # @param dry_run [Boolean] whether to collect detailed eligibility information
271
+ # @return [Boolean] true when all applicable conditions are eligible
80
272
  def eligible_by_applicable_conditions?(promotable, dry_run: false)
81
- applicable_conditions = conditions.select do |condition|
82
- condition.applicable?(promotable)
83
- end
84
-
85
- applicable_conditions.map do |applicable_condition|
86
- eligible = applicable_condition.eligible?(promotable)
273
+ conditions.map do |condition|
274
+ next unless condition.applicable?(promotable)
275
+ eligible = condition.eligible?(promotable)
87
276
 
88
277
  break [false] if !eligible && !dry_run
89
278
 
90
279
  if dry_run
91
- if applicable_condition.eligibility_errors.details[:base].first
92
- code = applicable_condition.eligibility_errors.details[:base].first[:error_code]
93
- message = applicable_condition.eligibility_errors.full_messages.first
280
+ if condition.eligibility_errors.details[:base].first
281
+ code = condition.eligibility_errors.details[:base].first[:error_code]
282
+ message = condition.eligibility_errors.full_messages.first
94
283
  end
95
284
  promotion.eligibility_results.add(
96
285
  item: promotable,
97
- condition: applicable_condition,
286
+ condition: condition,
98
287
  success: eligible,
99
288
  code: eligible ? nil : (code || :coupon_code_unknown_error),
100
289
  message: eligible ? nil : (message || I18n.t(:coupon_code_unknown_error, scope: [:solidus_promotions, :eligibility_errors]))
@@ -102,21 +291,47 @@ module SolidusPromotions
102
291
  end
103
292
 
104
293
  eligible
105
- end.all?
294
+ end.compact.all?
106
295
  end
107
296
 
297
+ # All line items of the order that are eligible for this benefit.
298
+ #
299
+ # @param order [Spree::Order]
300
+ # @return [Array<Spree::LineItem>] eligible line items
108
301
  def applicable_line_items(order)
109
302
  order.discountable_line_items.select do |line_item|
110
303
  eligible_by_applicable_conditions?(line_item)
111
304
  end
112
305
  end
113
306
 
307
+ # Base set of order-level condition classes available to all benefits.
308
+ #
309
+ # These generic order conditions apply regardless of the concrete benefit
310
+ # type, as every benefit ultimately operates within the context of an order.
311
+ # Concrete benefit subclasses may extend or override this to include
312
+ # additional applicable conditions that are specific to their discount
313
+ # target (e.g., line-item or shipment conditions).
314
+ #
315
+ # @return [Set<Class<SolidusPromotions::Condition>>]
114
316
  def possible_conditions
115
- Set.new(SolidusPromotions.config.order_conditions)
317
+ Spree.deprecator.warn("Use #{self.class.name}.applicable_conditions instead.")
318
+ self.class.applicable_conditions
116
319
  end
117
320
 
118
321
  private
119
322
 
323
+ def discount_method_for(adjustable)
324
+ :"discount_#{adjustable.class.name.demodulize.underscore}"
325
+ end
326
+
327
+ def adjustment_label_method_for(adjustable)
328
+ :"#{adjustable.class.name.demodulize.underscore}_adjustment_label"
329
+ end
330
+
331
+ # Prevents destroying a benefit when it has adjustments on completed orders.
332
+ #
333
+ # Adds an error and aborts the destroy callback chain when such adjustments exist.
334
+ # @api private
120
335
  def raise_for_adjustments_for_completed_orders
121
336
  if adjustments.joins(:order).merge(Spree::Order.complete).any?
122
337
  errors.add(:base, :cannot_destroy_if_order_completed)
@@ -3,10 +3,35 @@
3
3
  module SolidusPromotions
4
4
  module Benefits
5
5
  class AdjustLineItem < Benefit
6
- include SolidusPromotions::Benefits::LineItemBenefit
6
+ def self.applicable_conditions
7
+ SolidusPromotions::Condition.applicable_to([Spree::Order, Spree::LineItem])
8
+ end
9
+
10
+ def discount_line_item(line_item, ...)
11
+ adjustment = find_adjustment(line_item) || build_adjustment(line_item)
12
+ adjustment.amount = compute_amount(line_item, ...)
13
+ adjustment.label = adjustment_label(line_item)
14
+ adjustment
15
+ end
16
+
17
+ def level
18
+ :line_item
19
+ end
20
+ deprecate :level, deprecator: Spree.deprecator
21
+
22
+ private
23
+
24
+ def find_adjustment(line_item)
25
+ line_item.adjustments.detect do |adjustment|
26
+ adjustment.source == self
27
+ end
28
+ end
7
29
 
8
- def possible_conditions
9
- super + SolidusPromotions.config.line_item_conditions
30
+ def build_adjustment(line_item)
31
+ line_item.adjustments.build(
32
+ order: line_item.order,
33
+ source: self
34
+ )
10
35
  end
11
36
  end
12
37
  end
@@ -90,6 +90,7 @@ module SolidusPromotions
90
90
  end
91
91
 
92
92
  def discountable_amount
93
+ return Spree::ZERO if @line_item.quantity.zero?
93
94
  @line_item.discountable_amount / @line_item.quantity.to_d
94
95
  end
95
96
  alias_method :amount, :discountable_amount
@@ -3,10 +3,52 @@
3
3
  module SolidusPromotions
4
4
  module Benefits
5
5
  class AdjustShipment < Benefit
6
- include SolidusPromotions::Benefits::ShipmentBenefit
6
+ def self.applicable_conditions
7
+ SolidusPromotions::Condition.applicable_to([Spree::Order, Spree::Shipment])
8
+ end
9
+
10
+ def discount_shipment(shipment, ...)
11
+ adjustment = find_adjustment(shipment) || build_adjustment(shipment)
12
+ adjustment.amount = compute_amount(shipment, ...)
13
+ adjustment.label = adjustment_label(shipment)
14
+ adjustment
15
+ end
16
+
17
+ def discount_shipping_rate(shipping_rate, ...)
18
+ discount = find_discount(shipping_rate) || build_discount(shipping_rate)
19
+ discount.amount = compute_amount(shipping_rate, ...)
20
+ discount.label = adjustment_label(shipping_rate)
21
+ discount
22
+ end
23
+
24
+ def level
25
+ :shipment
26
+ end
27
+ deprecate :level, deprecator: Spree.deprecator
28
+
29
+ private
30
+
31
+ def find_adjustment(shipment)
32
+ shipment.adjustments.detect do |adjustment|
33
+ adjustment.source == self
34
+ end
35
+ end
36
+
37
+ def build_adjustment(shipment)
38
+ shipment.adjustments.build(
39
+ order: shipment.order,
40
+ source: self
41
+ )
42
+ end
43
+
44
+ def find_discount(shipping_rate)
45
+ shipping_rate.discounts.detect do |discount|
46
+ discount.benefit == self
47
+ end
48
+ end
7
49
 
8
- def possible_conditions
9
- super + SolidusPromotions.config.shipment_conditions
50
+ def build_discount(shipping_rate)
51
+ shipping_rate.discounts.build(benefit: self)
10
52
  end
11
53
  end
12
54
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ module Benefits
5
+ class AdvertisePrice < Benefit
6
+ def self.applicable_conditions
7
+ Condition.applicable_to([Spree::Order, Spree::Price])
8
+ end
9
+
10
+ def discount_price(price, ...)
11
+ discount = find_discount(price) || build_discount
12
+ discount.amount = compute_amount(price, ...)
13
+ discount.label = adjustment_label(price)
14
+ discount
15
+ end
16
+
17
+ private
18
+
19
+ def find_discount(price)
20
+ price.discounts.detect { |discount| discount.source == self }
21
+ end
22
+
23
+ def build_discount
24
+ SolidusPromotions::ItemDiscount.new(source: self)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -3,30 +3,53 @@
3
3
  module SolidusPromotions
4
4
  module Benefits
5
5
  class CreateDiscountedItem < Benefit
6
- include OrderBenefit
7
6
  preference :variant_id, :integer
8
7
  preference :quantity, :integer, default: 1
9
8
  preference :necessary_quantity, :integer, default: 1
10
9
 
11
10
  def perform(order)
12
- line_item = find_item(order) || create_item(order)
11
+ line_item = find_item(order) || build_item(order)
13
12
  set_quantity(line_item, determine_item_quantity(order))
14
- line_item.current_discounts << discount(line_item)
13
+ discount_line_item(line_item)
15
14
  end
16
15
 
17
16
  def remove_from(order)
18
- line_item = find_item(order)
19
- order.line_items.destroy(line_item)
17
+ find_item(order)&.mark_for_destruction
20
18
  end
21
19
 
20
+ def level
21
+ :order
22
+ end
23
+ deprecate :level, deprecator: Spree.deprecator
24
+
22
25
  private
23
26
 
27
+ def discount_line_item(line_item, ...)
28
+ adjustment = find_adjustment(line_item) || build_adjustment(line_item)
29
+ adjustment.amount = compute_amount(line_item, ...)
30
+ adjustment.label = adjustment_label(line_item)
31
+ adjustment
32
+ end
33
+
34
+ def find_adjustment(line_item)
35
+ line_item.adjustments.detect do |adjustment|
36
+ adjustment.source == self
37
+ end
38
+ end
39
+
40
+ def build_adjustment(line_item)
41
+ line_item.adjustments.build(
42
+ order: line_item.order,
43
+ source: self
44
+ )
45
+ end
46
+
24
47
  def find_item(order)
25
48
  order.line_items.detect { |line_item| line_item.managed_by_order_benefit == self }
26
49
  end
27
50
 
28
- def create_item(order)
29
- order.line_items.create!(quantity: determine_item_quantity(order), variant: variant, managed_by_order_benefit: self)
51
+ def build_item(order)
52
+ order.line_items.build(quantity: determine_item_quantity(order), variant: variant, managed_by_order_benefit: self)
30
53
  end
31
54
 
32
55
  def determine_item_quantity(order)
@@ -2,24 +2,50 @@
2
2
 
3
3
  require_dependency "spree/calculator"
4
4
 
5
- # This is a calculator for line item adjustment benefits. It accepts a line item
6
- # and calculates its weighted adjustment amount based on the value of the
7
- # preferred amount and the price of the other line items. More expensive line
8
- # items will receive a greater share of the preferred amount.
9
5
  module SolidusPromotions
10
6
  module Calculators
7
+ # A calculator that distributes a fixed discount amount across line items based on their value.
8
+ #
9
+ # This calculator takes a preferred total discount amount and distributes it proportionally
10
+ # across applicable line items based on their prices. More expensive line items receive
11
+ # a greater share of the discount.
12
+ #
13
+ # @example
14
+ # # Given a $30 discount and line items worth $100, $50, and $50:
15
+ # # - $100 line item receives $15 discount (50% of total value)
16
+ # # - $50 line item receives $7.50 discount (25% of total value)
17
+ # # - $50 line item receives $7.50 discount (25% of total value)
11
18
  class DistributedAmount < Spree::Calculator
12
19
  include PromotionCalculator
13
20
 
14
- preference :amount, :decimal, default: 0
21
+ preference :amount, :decimal, default: Spree::ZERO
15
22
  preference :currency, :string, default: -> { Spree::Config[:currency] }
16
23
 
24
+ # Computes the weighted discount amount for a specific line item.
25
+ #
26
+ # The discount is calculated by distributing the preferred amount across all
27
+ # applicable line items, weighted by their prices. Returns 0 if:
28
+ # - The line item is nil
29
+ # - The currency doesn't match the preferred currency
30
+ # - The line item is not in the list of applicable line items
31
+ #
32
+ # @param line_item [Spree::LineItem] The line item to calculate the discount for
33
+ #
34
+ # @return [BigDecimal] The weighted discount amount for this line item
35
+ #
36
+ # @example Computing discount for a line item
37
+ # calculator = DistributedAmount.new(preferred_amount: 20, preferred_currency: 'USD')
38
+ # # Assuming there are 2 line items: one at $80, one at $20
39
+ # calculator.compute_line_item(expensive_line_item) # => 16.0 (80% of 20)
40
+ # calculator.compute_line_item(cheaper_line_item) # => 4.0 (20% of 20)
41
+ #
42
+ # @see DistributedAmountsHandler
17
43
  def compute_line_item(line_item)
18
- return 0 unless line_item
19
- return 0 unless preferred_currency.casecmp(line_item.currency).zero?
44
+ return Spree::ZERO unless line_item
45
+ return Spree::ZERO unless preferred_currency.casecmp(line_item.currency).zero?
20
46
 
21
47
  distributable_line_items = calculable.applicable_line_items(line_item.order)
22
- return 0 unless line_item.in?(distributable_line_items)
48
+ return Spree::ZERO unless line_item.in?(distributable_line_items)
23
49
 
24
50
  DistributedAmountsHandler.new(
25
51
  distributable_line_items,