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
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ module DiscountedAmount
5
+ class NotCalculatingPromotions < StandardError
6
+ DEFAULT_MESSAGE = <<~MSG
7
+ You're trying to call `#current_lane_discounts` without a current lane being set on `SolidusPromotions::PromotionLane.
8
+ In order to set a current lane, wrap your call into a `PromotionLane.set` block:
9
+ ```
10
+ SolidusPromotions::PromotionLane.set(current_lane: "default") do
11
+ # YOUR CODE HERE
12
+ end
13
+ ```
14
+ MSG
15
+
16
+ def initialize
17
+ super(DEFAULT_MESSAGE)
18
+ end
19
+ end
20
+
21
+ # Calculates the total discounted amount including adjustments from previous lanes.
22
+ #
23
+ # @return [BigDecimal] the sum of the current amount and all previous lane discount amounts
24
+ def discounted_amount
25
+ amount + previous_lanes_discounts.sum(&:amount)
26
+ end
27
+ # The discountable amount is always equal to the discounted amount.
28
+ alias_method :discountable_amount, :discounted_amount
29
+
30
+ # Returns discount objects from the current promotion lane.
31
+ #
32
+ # @return [Array<Spree::Adjustment,SolidusPromotions::ShippingRateDiscount>] Discounts from the current lane
33
+ # @raise [NotCalculatingPromotions] if no promotion lane is currently being calculated
34
+ def current_lane_discounts
35
+ raise NotCalculatingPromotions unless PromotionLane.current_lane
36
+
37
+ discounts_by_lanes([PromotionLane.current_lane])
38
+ end
39
+
40
+ private
41
+
42
+ # Returns discount objects added by promotion in lanes that come before the current lane.
43
+ #
44
+ # This method retrieves all discounts that were applied by promotion lanes with a priority
45
+ # lower than the current lane, effectively getting discounts from earlier processing stages.
46
+ #
47
+ # @return [Array<Spree::Adjustment,SolidusPromotions::ShippingRateDiscount>] Discounts from previous lanes
48
+ # @see #discounts_by_lanes
49
+ # @see PromotionLane.previous_lanes
50
+ def previous_lanes_discounts
51
+ discounts_by_lanes(PromotionLane.previous_lanes)
52
+ end
53
+ end
54
+ end
@@ -1,59 +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
 
40
+ # @!attribute [rw] promotion
41
+ # The owning promotion.
42
+ # @return [SolidusPromotions::Promotion]
15
43
  belongs_to :promotion, inverse_of: :benefits
44
+ # @!attribute [rw] original_promotion_action
45
+ # Back-reference to the original Solidus (Spree) promotion action, when migrated.
46
+ # @return [Spree::PromotionAction, nil]
16
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>]
17
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>]
18
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>]
19
59
  has_many :conditions, class_name: "SolidusPromotions::Condition", inverse_of: :benefit, dependent: :destroy
20
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>]
21
65
  scope :of_type, ->(type) { where(type: Array.wrap(type).map(&:to_s)) }
22
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
23
86
  def preload_relations
24
87
  [:calculator]
25
88
  end
26
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
27
100
  def can_discount?(object)
28
- raise NotImplementedError, "Please implement the correct interface, or include one of the `SolidusPromotions::Benefits::OrderBenefit`, " \
29
- "`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules"
101
+ respond_to?(discount_method_for(object))
30
102
  end
31
103
 
32
- def discount(adjustable)
33
- amount = compute_amount(adjustable)
34
- return if amount.zero?
35
- ItemDiscount.new(
36
- item: adjustable,
37
- label: adjustment_label(adjustable),
38
- amount: amount,
39
- source: self
40
- )
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
41
127
  end
42
128
 
43
- # Ensure a negative amount which does not exceed the object's amount
44
- def compute_amount(adjustable)
45
- 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
46
186
  [adjustable.discountable_amount, promotion_amount.abs].min * -1
47
187
  end
48
188
 
49
- def adjustment_label(adjustable)
50
- I18n.t(
51
- "solidus_promotions.adjustment_labels.#{adjustable.class.name.demodulize.underscore}",
52
- promotion: SolidusPromotions::Promotion.model_name.human,
53
- promotion_customer_label: promotion.customer_label
54
- )
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
55
233
  end
56
234
 
235
+ # Partial path used for admin forms for this benefit type.
236
+ #
237
+ # @return [String]
57
238
  def to_partial_path
58
239
  "solidus_promotions/admin/benefit_fields/#{model_name.element}"
59
240
  end
@@ -62,33 +243,47 @@ module SolidusPromotions
62
243
  raise NotImplementedError, "Please implement the correct interface, or include one of the `SolidusPromotions::Benefits::OrderBenefit`, " \
63
244
  "`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules"
64
245
  end
246
+ deprecate :level, deprecator: Spree.deprecator
65
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>>]
66
252
  def available_conditions
67
- possible_conditions - conditions.select(&:persisted?)
253
+ self.class.applicable_conditions - conditions.select(&:persisted?)
68
254
  end
69
255
 
256
+ # Returns the calculators allowed for this benefit type.
257
+ #
258
+ # @return [Array<Class>] calculator classes
70
259
  def available_calculators
71
260
  SolidusPromotions.config.promotion_calculators[self.class] || []
72
261
  end
73
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
74
272
  def eligible_by_applicable_conditions?(promotable, dry_run: false)
75
- applicable_conditions = conditions.select do |condition|
76
- condition.applicable?(promotable)
77
- end
78
-
79
- applicable_conditions.map do |applicable_condition|
80
- eligible = applicable_condition.eligible?(promotable)
273
+ conditions.map do |condition|
274
+ next unless condition.applicable?(promotable)
275
+ eligible = condition.eligible?(promotable)
81
276
 
82
277
  break [false] if !eligible && !dry_run
83
278
 
84
279
  if dry_run
85
- if applicable_condition.eligibility_errors.details[:base].first
86
- code = applicable_condition.eligibility_errors.details[:base].first[:error_code]
87
- 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
88
283
  end
89
284
  promotion.eligibility_results.add(
90
285
  item: promotable,
91
- condition: applicable_condition,
286
+ condition: condition,
92
287
  success: eligible,
93
288
  code: eligible ? nil : (code || :coupon_code_unknown_error),
94
289
  message: eligible ? nil : (message || I18n.t(:coupon_code_unknown_error, scope: [:solidus_promotions, :eligibility_errors]))
@@ -96,21 +291,47 @@ module SolidusPromotions
96
291
  end
97
292
 
98
293
  eligible
99
- end.all?
294
+ end.compact.all?
100
295
  end
101
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
102
301
  def applicable_line_items(order)
103
302
  order.discountable_line_items.select do |line_item|
104
303
  eligible_by_applicable_conditions?(line_item)
105
304
  end
106
305
  end
107
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>>]
108
316
  def possible_conditions
109
- Set.new(SolidusPromotions.config.order_conditions)
317
+ Spree.deprecator.warn("Use #{self.class.name}.applicable_conditions instead.")
318
+ self.class.applicable_conditions
110
319
  end
111
320
 
112
321
  private
113
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
114
335
  def raise_for_adjustments_for_completed_orders
115
336
  if adjustments.joins(:order).merge(Spree::Order.complete).any?
116
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,