Class: SolidusPromotions::Benefit

Inherits:
Spree::Base
  • Object
show all
Includes:
Spree::AdjustmentSource, Spree::CalculatedAdjustments, Spree::Preferences::Persistable
Defined in:
app/models/solidus_promotions/benefit.rb

Overview

Base class for all promotion benefits.

A Benefit is the active part of a promotion: once a promotion becomes eligible for a given promotable (order, line item, or shipment), the benefit determines how much discount to apply and produces the corresponding adjustments.

Subclasses specialize the discounting target (orders, line items, or shipments) and usually include one of the following mixins to integrate with Solidus’ adjustment system:

  • SolidusPromotions::Benefits::AdjustLineItem

  • SolidusPromotions::Benefits::AdjustShipment

  • SolidusPromotions::Benefits::CreateDiscountedItem

A benefit can discount any object for which #can_discount? returns true. Implementors must provide a calculator via Spree::CalculatedAdjustments and may override methods such as #adjustment_label.

Usage example

benefit = SolidusPromotions::Benefits::AdjustLineItem.new(promotion: promo)
if benefit.can_discount?(line_item)
  discount = benefit.discount(line_item)
  # => #<SolidusPromotions::ItemDiscount ...>
end

See Also:

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#adjustmentsActiveRecord::Associations::CollectionProxy<Spree::Adjustment> (readonly)

Adjustments created by this benefit.

Returns:

  • (ActiveRecord::Associations::CollectionProxy<Spree::Adjustment>)


51
# File 'app/models/solidus_promotions/benefit.rb', line 51

has_many :adjustments, class_name: "Spree::Adjustment", as: :source, dependent: :restrict_with_error

#conditionsActiveRecord::Associations::CollectionProxy<SolidusPromotions::Condition> (readonly)

Conditions attached to this benefit.

Returns:



59
# File 'app/models/solidus_promotions/benefit.rb', line 59

has_many :conditions, class_name: "SolidusPromotions::Condition", inverse_of: :benefit, dependent: :destroy

#original_promotion_actionSpree::PromotionAction?

Back-reference to the original Solidus (Spree) promotion action, when migrated.

Returns:

  • (Spree::PromotionAction, nil)


47
# File 'app/models/solidus_promotions/benefit.rb', line 47

belongs_to :original_promotion_action, class_name: "Spree::PromotionAction", optional: true

#promotionSolidusPromotions::Promotion

The owning promotion.



43
# File 'app/models/solidus_promotions/benefit.rb', line 43

belongs_to :promotion, inverse_of: :benefits

#shipping_rate_discountsActiveRecord::Associations::CollectionProxy<SolidusPromotions::ShippingRateDiscount> (readonly)

Shipping-rate-level discounts generated by this benefit.

Returns:



55
# File 'app/models/solidus_promotions/benefit.rb', line 55

has_many :shipping_rate_discounts, class_name: "SolidusPromotions::ShippingRateDiscount", inverse_of: :benefit, dependent: :restrict_with_error

Class Method Details

.applicable_conditionsEnumerable<Class<SolidusPromotions::Condition>>

Base set of order-level condition classes available to all benefits.

These generic order conditions apply regardless of the concrete benefit type, as every benefit ultimately operates within the context of an order. Concrete benefit subclasses may extend or override this to include additional applicable conditions that are specific to their discount target (e.g., line-item or shipment conditions).

Returns:



76
77
78
# File 'app/models/solidus_promotions/benefit.rb', line 76

def self.applicable_conditions
  SolidusPromotions::Condition.applicable_to([Spree::Order])
end

.inherited(klass) ⇒ Object



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'app/models/solidus_promotions/benefit.rb', line 129

def self.inherited(klass)
  def klass.method_added(method_added)
    if method_added == :discount
      Spree.deprecator.warn <<~MSG
        Please refactor `#{name}`. You're defining `#discount`. Instead, define a method for each type of discountable
        that your benefit can discount. For example:
        ```
        class MyBenefit < SolidusPromotions::Benefit
          def can_discount?(discountable)
            discountable.is_a?(Spree::LineItem)
          end

          def discount(order, _options = {})
            amount = compute_amount(line_item, ...)
            return if amount.zero?

            ItemDiscount.new(
              item: line_item,
              label: adjustment_label(line_item),
              amount: amount,
              source: self
            )
          end
        ```
        can now become
        ```
        class MyBenefit < SolidusPromotions::Benefit
          def discount_line_item(order, ...)
            amount = compute_amount(line_item, ...)
            return if amount.zero?

            ItemDiscount.new(
              item: line_item,
              label: adjustment_label(line_item),
              amount: amount,
              source: self
            )
          end
        end
        ```
      MSG
    end
    super
  end
  super
end

.of_type(type) ⇒ ActiveRecord::Relation<SolidusPromotions::Benefit>

Restricts benefits to the given STI type(s).

Parameters:

  • type (String, Symbol, Class, Array<String,Symbol,Class>)

    a single type or list of types

Returns:



65
# File 'app/models/solidus_promotions/benefit.rb', line 65

scope :of_type, ->(type) { where(type: Array.wrap(type).map(&:to_s)) }

Instance Method Details

#adjustment_label(adjustable) ⇒ String

Builds the localized label for adjustments created by this benefit.

This method attempts to use a calculator-specific label method if available, falling back to a localized string key based on the adjustable’s class name.

## Calculator Override

Calculators can provide custom labels by implementing a method named after the adjustable type. For example, a calculator that discounts line items could implement ‘line_item_adjustment_label`:

The method name follows the pattern: ‘adjustable_type_adjustment_label` where `adjustable_type` is the underscored class name of the adjustable (e.g., `line_item`, `shipment`, `shipping_rate`).

If the calculator does not respond to the expected method, the benefit will fall back to using an i18n translation key based on the adjustable’s class.

Examples:

Custom calculator with adjustment label

class MyCalculator < Spree::Calculator
  def compute(adjustable, *args)
    # calculation logic
  end

  def line_item_adjustment_label(line_item, *args)
    "Custom discount for #{line_item.product.name}"
  end
end

Parameters:

  • adjustable (Object)

    the object being discounted (e.g., Spree::LineItem, Spree::Shipment)

  • ... (args, kwargs)

    additional arguments forwarded to the calculator’s label method

Returns:

  • (String)

    a localized label suitable for display in adjustments

See Also:

  • #adjustment_label_method_for


223
224
225
226
227
228
229
230
231
232
233
# File 'app/models/solidus_promotions/benefit.rb', line 223

def adjustment_label(adjustable, ...)
  if calculator.respond_to?(adjustment_label_method_for(adjustable))
    calculator.send(adjustment_label_method_for(adjustable), adjustable, ...)
  else
    I18n.t(
      "solidus_promotions.adjustment_labels.#{adjustable.class.name.demodulize.underscore}",
      promotion: SolidusPromotions::Promotion.model_name.human,
      promotion_customer_label: promotion.customer_label
    )
  end
end

#applicable_line_items(order) ⇒ Array<Spree::LineItem>

All line items of the order that are eligible for this benefit.

Parameters:

  • order (Spree::Order)

Returns:

  • (Array<Spree::LineItem>)

    eligible line items



301
302
303
304
305
# File 'app/models/solidus_promotions/benefit.rb', line 301

def applicable_line_items(order)
  order.discountable_line_items.select do |line_item|
    eligible_by_applicable_conditions?(line_item)
  end
end

#available_calculatorsArray<Class>

Returns the calculators allowed for this benefit type.

Returns:

  • (Array<Class>)

    calculator classes



259
260
261
# File 'app/models/solidus_promotions/benefit.rb', line 259

def available_calculators
  SolidusPromotions.config.promotion_calculators[self.class] || []
end

#available_conditionsSet<Class<SolidusPromotions::Condition>>

Returns the set of condition classes that can still be attached to this benefit. Already-persisted conditions are excluded.

Returns:



252
253
254
# File 'app/models/solidus_promotions/benefit.rb', line 252

def available_conditions
  self.class.applicable_conditions - conditions.select(&:persisted?)
end

#can_discount?(object) ⇒ Boolean

Whether this benefit can discount the given object.

Subclasses must implement this according to the kinds of objects they are able to discount.

Parameters:

  • object (Object)

    a potential adjustable (order, line item, or shipment)

Returns:

  • (Boolean)

See Also:

  • SolidusPromotions::Benefits::AdjustShipment, SolidusPromotions::Benefits::CreateDiscountedItem


100
101
102
# File 'app/models/solidus_promotions/benefit.rb', line 100

def can_discount?(object)
  respond_to?(discount_method_for(object))
end

#compute_amount(adjustable) ⇒ BigDecimal

Computes the discount amount for the given adjustable.

Ensures the returned amount is negative and does not exceed the adjustable’s discountable amount.

Parameters:

  • adjustable (#discountable_amount)

    the adjustable to compute for

  • ... (args, kwargs)

    additional arguments forwarded to the calculator

Returns:

  • (BigDecimal)

    a negative amount suitable for creating an adjustment



184
185
186
187
# File 'app/models/solidus_promotions/benefit.rb', line 184

def compute_amount(adjustable, ...)
  promotion_amount = calculator.compute(adjustable, ...) || Spree::ZERO
  [adjustable.discountable_amount, promotion_amount.abs].min * -1
end

#discount(adjustable) ⇒ Spree::Adjustment, ...

Calculates and returns a discount for the given adjustable object.

This method computes the discount amount using the benefit’s calculator and returns an ItemDiscount object representing the discount to be applied. If the computed amount is zero, no discount is returned.

Examples:

Calculating a discount for a line item

benefit.discount(line_item)
# => #<Spree::Adjustment, adjustable: line_item, amount: -10.00, ...>

Parameters:

  • adjustable (Object)

    The object to calculate the discount for (e.g., LineItem, Shipment, ShippingRate)

  • ... (args, kwargs)

    Additional arguments passed to the calculator’s compute method

Returns:

See Also:



121
122
123
124
125
126
127
# File 'app/models/solidus_promotions/benefit.rb', line 121

def discount(adjustable, ...)
  if can_discount?(adjustable)
    send(discount_method_for(adjustable), adjustable, ...)
  else
    raise NotImplementedError, "Please implement #{discount_method_for(adjustable)} in your condition"
  end
end

#eligible_by_applicable_conditions?(promotable, dry_run: false) ⇒ Boolean

Verifies if the promotable satisfies all applicable conditions of this benefit.

When dry_run is true, an EligibilityResults entry is recorded for each condition with success/error details; otherwise, the evaluation short-circuits on the first failure.

Parameters:

  • promotable (Object)

    the entity being evaluated (e.g., Spree::Order, Spree::LineItem)

  • dry_run (Boolean) (defaults to: false)

    whether to collect detailed eligibility information

Returns:

  • (Boolean)

    true when all applicable conditions are eligible



272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'app/models/solidus_promotions/benefit.rb', line 272

def eligible_by_applicable_conditions?(promotable, dry_run: false)
  conditions.map do |condition|
    next unless condition.applicable?(promotable)
    eligible = condition.eligible?(promotable)

    break [false] if !eligible && !dry_run

    if dry_run
      if condition.eligibility_errors.details[:base].first
        code = condition.eligibility_errors.details[:base].first[:error_code]
        message = condition.eligibility_errors.full_messages.first
      end
      promotion.eligibility_results.add(
        item: promotable,
        condition: condition,
        success: eligible,
        code: eligible ? nil : (code || :coupon_code_unknown_error),
        message: eligible ? nil : (message || I18n.t(:coupon_code_unknown_error, scope: [:solidus_promotions, :eligibility_errors]))
      )
    end

    eligible
  end.compact.all?
end

#levelObject

Raises:

  • (NotImplementedError)


242
243
244
245
# File 'app/models/solidus_promotions/benefit.rb', line 242

def level
  raise NotImplementedError, "Please implement the correct interface, or include one of the `SolidusPromotions::Benefits::OrderBenefit`, " \
    "`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules"
end

#possible_conditionsSet<Class<SolidusPromotions::Condition>>

Base set of order-level condition classes available to all benefits.

These generic order conditions apply regardless of the concrete benefit type, as every benefit ultimately operates within the context of an order. Concrete benefit subclasses may extend or override this to include additional applicable conditions that are specific to their discount target (e.g., line-item or shipment conditions).

Returns:



316
317
318
319
# File 'app/models/solidus_promotions/benefit.rb', line 316

def possible_conditions
  Spree.deprecator.warn("Use #{self.class.name}.applicable_conditions instead.")
  self.class.applicable_conditions
end

#preload_relationsArray<Symbol>

Returns relations that should be preloaded for this condition.

Override this method in subclasses to specify associations that should be eager loaded to avoid N+1 queries when computing discounts or performing automations.

Returns:

  • (Array<Symbol>)

    An array of association names to preload



86
87
88
# File 'app/models/solidus_promotions/benefit.rb', line 86

def preload_relations
  [:calculator]
end

#to_partial_pathString

Partial path used for admin forms for this benefit type.

Returns:

  • (String)


238
239
240
# File 'app/models/solidus_promotions/benefit.rb', line 238

def to_partial_path
  "solidus_promotions/admin/benefit_fields/#{model_name.element}"
end