11# frozen_string_literal: true
22
33module SolidusPromotions
4- # Base class for all types of benefit .
4+ # Base class for all promotion benefits .
55 #
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::OrderBenefit
15+ # - SolidusPromotions::Benefits::LineItemBenefit
16+ # - SolidusPromotions::Benefits::ShipmentBenefit
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
832 class Benefit < Spree ::Base
933 include Spree ::Preferences ::Persistable
1034 include Spree ::CalculatedAdjustments
1135 include Spree ::AdjustmentSource
36+
1237 before_destroy :remove_adjustments_from_incomplete_orders
1338 before_destroy :raise_for_adjustments_for_completed_orders
1439
40+ # @!attribute [rw] promotion
41+ # The owning promotion.
42+ # @return [SolidusPromotions::Promotion]
1543 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]
1647 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>]
1751 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>]
1855 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>]
1959 has_many :conditions , class_name : "SolidusPromotions::Condition" , inverse_of : :benefit , dependent : :destroy
2060
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>]
2165 scope :of_type , -> ( type ) { where ( type : Array . wrap ( type ) . map ( &:to_s ) ) }
2266
67+ # Returns relations that should be preloaded for this condition.
68+ #
69+ # Override this method in subclasses to specify associations that should be eager loaded
70+ # to avoid N+1 queries when evaluating conditions.
71+ #
72+ # @return [Array<Symbol>] An array of association names to preload
2373 def preload_relations
2474 [ :calculator ]
2575 end
2676
77+ # Whether this benefit can discount the given object.
78+ #
79+ # Subclasses must implement this according to the kinds
80+ # of objects they are able to discount.
81+ #
82+ # @param object [Object] a potential adjustable (order, line item, or shipment)
83+ # @return [Boolean]
84+ # @raise [NotImplementedError] when not implemented by the subclass/mixin
85+ # @see SolidusPromotions::Benefits::OrderBenefit,
86+ # SolidusPromotions::Benefits::LineItemBenefit,
87+ # SolidusPromotions::Benefits::ShipmentBenefit
2788 def can_discount? ( object )
2889 raise NotImplementedError , "Please implement the correct interface, or include one of the `SolidusPromotions::Benefits::OrderBenefit`, " \
2990 "`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules"
@@ -57,12 +118,23 @@ def discount(adjustable, ...)
57118 )
58119 end
59120
60- # Ensure a negative amount which does not exceed the object's amount
121+ # Computes the discount amount for the given adjustable.
122+ #
123+ # Ensures the returned amount is negative and does not exceed the
124+ # adjustable's discountable amount.
125+ #
126+ # @param adjustable [#discountable_amount] the adjustable to compute for
127+ # @param ... [args, kwargs] additional arguments forwarded to the calculator
128+ # @return [BigDecimal] a negative amount suitable for creating an adjustment
61129 def compute_amount ( adjustable , ...)
62130 promotion_amount = calculator . compute ( adjustable , ...) || Spree ::ZERO
63131 [ adjustable . discountable_amount , promotion_amount . abs ] . min * -1
64132 end
65133
134+ # Builds the localized label for adjustments created by this benefit.
135+ #
136+ # @param adjustable [Object]
137+ # @return [String]
66138 def adjustment_label ( adjustable )
67139 I18n . t (
68140 "solidus_promotions.adjustment_labels.#{ adjustable . class . name . demodulize . underscore } " ,
@@ -71,6 +143,9 @@ def adjustment_label(adjustable)
71143 )
72144 end
73145
146+ # Partial path used for admin forms for this benefit type.
147+ #
148+ # @return [String]
74149 def to_partial_path
75150 "solidus_promotions/admin/benefit_fields/#{ model_name . element } "
76151 end
@@ -80,14 +155,30 @@ def level
80155 "`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules"
81156 end
82157
158+ # Returns the set of condition classes that can still be attached to this benefit.
159+ # Already-persisted conditions are excluded.
160+ #
161+ # @return [Set<Class<SolidusPromotions::Condition>>]
83162 def available_conditions
84163 possible_conditions - conditions . select ( &:persisted? )
85164 end
86165
166+ # Returns the calculators allowed for this benefit type.
167+ #
168+ # @return [Array<Class>] calculator classes
87169 def available_calculators
88170 SolidusPromotions . config . promotion_calculators [ self . class ] || [ ]
89171 end
90172
173+ # Verifies if the promotable satisfies all applicable conditions of this benefit.
174+ #
175+ # When dry_run is true, an {SolidusPromotions::EligibilityResults} entry is
176+ # recorded for each condition with success/error details; otherwise, the
177+ # evaluation short-circuits on the first failure.
178+ #
179+ # @param promotable [Object] the entity being evaluated (e.g., Spree::Order, Spree::LineItem)
180+ # @param dry_run [Boolean] whether to collect detailed eligibility information
181+ # @return [Boolean] true when all applicable conditions are eligible
91182 def eligible_by_applicable_conditions? ( promotable , dry_run : false )
92183 applicable_conditions = conditions . select do |condition |
93184 condition . applicable? ( promotable )
@@ -116,18 +207,35 @@ def eligible_by_applicable_conditions?(promotable, dry_run: false)
116207 end . all?
117208 end
118209
210+ # All line items of the order that are eligible for this benefit.
211+ #
212+ # @param order [Spree::Order]
213+ # @return [Array<Spree::LineItem>] eligible line items
119214 def applicable_line_items ( order )
120215 order . discountable_line_items . select do |line_item |
121216 eligible_by_applicable_conditions? ( line_item )
122217 end
123218 end
124219
220+ # Base set of order-level condition classes available to all benefits.
221+ #
222+ # These generic order conditions apply regardless of the concrete benefit
223+ # type, as every benefit ultimately operates within the context of an order.
224+ # Concrete benefit subclasses may extend or override this to include
225+ # additional applicable conditions that are specific to their discount
226+ # target (e.g., line-item or shipment conditions).
227+ #
228+ # @return [Set<Class<SolidusPromotions::Condition>>]
125229 def possible_conditions
126230 Set . new ( SolidusPromotions . config . order_conditions )
127231 end
128232
129233 private
130234
235+ # Prevents destroying a benefit when it has adjustments on completed orders.
236+ #
237+ # Adds an error and aborts the destroy callback chain when such adjustments exist.
238+ # @api private
131239 def raise_for_adjustments_for_completed_orders
132240 if adjustments . joins ( :order ) . merge ( Spree ::Order . complete ) . any?
133241 errors . add ( :base , :cannot_destroy_if_order_completed )
0 commit comments