11# frozen_string_literal: true
22
33module 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
446 class Condition < Spree ::Base
547 include Spree ::Preferences ::Persistable
648
@@ -12,46 +54,150 @@ class Condition < Spree::Base
1254 validate :unique_per_benefit , on : :create
1355 validate :possible_condition_for_benefit , if : -> { benefit . present? }
1456
57+ # Returns relations that should be preloaded for this condition.
58+ #
59+ # Override this method in subclasses to specify associations that should be eager loaded
60+ # to avoid N+1 queries when evaluating conditions.
61+ #
62+ # @return [Array<Symbol>] An array of association names to preload
63+ #
64+ # @example Preloading products association
65+ # def preload_relations
66+ # [:products]
67+ # end
1568 def preload_relations
1669 [ ]
1770 end
1871
72+ # Determines if this condition can be applied to a given promotable object.
73+ #
74+ # This method is typically implemented by including one of the level modules
75+ # (OrderLevelCondition, LineItemLevelCondition, or LineItemApplicableOrderLevelCondition)
76+ # rather than being overridden directly.
77+ #
78+ # @param _promotable [Object] The object to check (e.g., Spree::Order, Spree::LineItem)
79+ #
80+ # @return [Boolean] true if this condition applies to the promotable type
81+ #
82+ # @raise [NotImplementedError] if not implemented in subclass
83+ #
84+ # @example Order-level condition applicability
85+ # condition.applicable?(order) # => true
86+ # condition.applicable?(line_item) # => false
87+ #
88+ # @see OrderLevelCondition
89+ # @see LineItemLevelCondition
90+ # @see LineItemApplicableOrderLevelCondition
1991 def applicable? ( _promotable )
20- raise NotImplementedError , "applicable? should be implemented in a sub-class of SolidusPromotions::Rule "
92+ raise NotImplementedError , "applicable? should be implemented in a sub-class of SolidusPromotions::Condition "
2193 end
2294
95+ # Determines if the promotable object meets this condition's eligibility requirements.
96+ #
97+ # This is the core method that implements the condition's logic. When the promotable
98+ # is not eligible, this method should add errors to {#eligibility_errors} explaining why.
99+ #
100+ # @param _promotable [Object] The object to evaluate (e.g., Spree::Order, Spree::LineItem)
101+ # @param _options [Hash] Additional options for eligibility checking
102+ #
103+ # @return [Boolean] true if the promotable meets the condition, false otherwise
104+ #
105+ # @raise [NotImplementedError] if not implemented in subclass
106+ #
107+ # @example Order total condition
108+ # def eligible?(order, _options = {})
109+ # if order.item_total < preferred_minimum
110+ # eligibility_errors.add(:base, "Order total too low")
111+ # end
112+ # eligibility_errors.empty?
113+ # end
114+ #
115+ # @example First order condition
116+ # def eligible?(order, _options = {})
117+ # if order.user.orders.complete.count > 1
118+ # eligibility_errors.add(:base, "Not first order")
119+ # end
120+ # eligibility_errors.empty?
121+ # end
122+ #
123+ # @see #eligibility_errors
23124 def eligible? ( _promotable , _options = { } )
24- raise NotImplementedError , "eligible? should be implemented in a sub-class of SolidusPromotions::Rule "
125+ raise NotImplementedError , "eligible? should be implemented in a sub-class of SolidusPromotions::Condition "
25126 end
26127
27128 def level
28- raise NotImplementedError , "level should be implemented in a sub-class of SolidusPromotions::Rule "
129+ raise NotImplementedError , "level should be implemented in a sub-class of SolidusPromotions::Condition "
29130 end
30131
132+ # Returns an errors object for tracking eligibility failures.
133+ #
134+ # When {#eligible?} determines that a promotable doesn't meet the condition,
135+ # it should add descriptive errors to this object. These errors are used to
136+ # provide feedback about why a promotion isn't being applied.
137+ #
138+ # @return [ActiveModel::Errors] An errors collection for this condition
139+ #
140+ # @example Adding an eligibility error
141+ # def eligible?(order, _options = {})
142+ # if order.item_total < 50
143+ # eligibility_errors.add(:base, "Minimum order is $50", error_code: :item_total_too_low)
144+ # end
145+ # eligibility_errors.empty?
146+ # end
31147 def eligibility_errors
32148 @eligibility_errors ||= ActiveModel ::Errors . new ( self )
33149 end
34150
151+ # Returns the partial path for rendering this condition in the admin interface.
152+ #
153+ # @return [String] The path to the admin form partial for this condition
154+ #
155+ # @example
156+ # # For SolidusPromotions::Conditions::ItemTotal
157+ # # => "solidus_promotions/admin/condition_fields/item_total"
35158 def to_partial_path
36159 "solidus_promotions/admin/condition_fields/#{ model_name . element } "
37160 end
38161
162+ # Determines if this condition can be updated in the admin interface.
163+ #
164+ # A condition is considered updateable if it has any preferences that can be configured.
165+ #
166+ # @return [Boolean] true if the condition has configurable preferences
39167 def updateable?
40168 preferences . any?
41169 end
42170
43171 private
44172
173+ # Validates that only one instance of this condition type exists per benefit.
174+ #
175+ # Prevents duplicate conditions of the same type from being added to a single benefit.
45176 def unique_per_benefit
46177 return unless self . class . exists? ( benefit_id : benefit_id , type : self . class . name )
47178
48179 errors . add ( :benefit , :already_contains_condition_type )
49180 end
50181
182+ # Validates that this condition type is allowed for the associated benefit.
183+ #
184+ # Checks the benefit's {Benefit#possible_conditions} to ensure this condition
185+ # type is compatible.
51186 def possible_condition_for_benefit
52187 benefit . possible_conditions . include? ( self . class ) || errors . add ( :type , :invalid_condition_type )
53188 end
54189
190+ # Generates a translated eligibility error message.
191+ #
192+ # Looks up the error message in the I18n translations under the condition's scope.
193+ #
194+ # @param key [Symbol] The I18n key for the error message
195+ # @param options [Hash] Interpolation options for the message
196+ #
197+ # @return [String] The translated error message
198+ #
199+ # @example
200+ # eligibility_error_message(:item_total_too_low, minimum: "$50")
55201 def eligibility_error_message ( key , options = { } )
56202 I18n . t ( key , scope : [ :solidus_promotions , :eligibility_errors , self . class . name . underscore ] , **options )
57203 end
0 commit comments