Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 112 additions & 4 deletions promotions/app/models/solidus_promotions/benefit.rb
Original file line number Diff line number Diff line change
@@ -1,29 +1,90 @@
# frozen_string_literal: true

module SolidusPromotions
# Base class for all types of benefit.
# Base class for all promotion benefits.
#
# Benefits perform the necessary tasks when a promotion is activated
# by an event and determined to be eligible.
# 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::OrderBenefit
# - SolidusPromotions::Benefits::LineItemBenefit
# - SolidusPromotions::Benefits::ShipmentBenefit
#
# 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 SolidusPromotions::Promotion
# @see Spree::CalculatedAdjustments
class Benefit < Spree::Base
include Spree::Preferences::Persistable
include Spree::CalculatedAdjustments
include Spree::AdjustmentSource

before_destroy :remove_adjustments_from_incomplete_orders
before_destroy :raise_for_adjustments_for_completed_orders

# @!attribute [rw] promotion
# The owning promotion.
# @return [SolidusPromotions::Promotion]
belongs_to :promotion, inverse_of: :benefits
# @!attribute [rw] original_promotion_action
# Back-reference to the original Solidus (Spree) promotion action, when migrated.
# @return [Spree::PromotionAction, nil]
belongs_to :original_promotion_action, class_name: "Spree::PromotionAction", optional: true
# @!attribute [r] adjustments
# Adjustments created by this benefit.
# @return [ActiveRecord::Associations::CollectionProxy<Spree::Adjustment>]
has_many :adjustments, class_name: "Spree::Adjustment", as: :source, dependent: :restrict_with_error
# @!attribute [r] shipping_rate_discounts
# Shipping-rate-level discounts generated by this benefit.
# @return [ActiveRecord::Associations::CollectionProxy<SolidusPromotions::ShippingRateDiscount>]
has_many :shipping_rate_discounts, class_name: "SolidusPromotions::ShippingRateDiscount", inverse_of: :benefit, dependent: :restrict_with_error
# @!attribute [r] conditions
# Conditions attached to this benefit.
# @return [ActiveRecord::Associations::CollectionProxy<SolidusPromotions::Condition>]
has_many :conditions, class_name: "SolidusPromotions::Condition", inverse_of: :benefit, dependent: :destroy

# @!method self.of_type(type)
# Restricts benefits to the given STI type(s).
# @param type [String, Symbol, Class, Array<String,Symbol,Class>] a single type or list of types
# @return [ActiveRecord::Relation<SolidusPromotions::Benefit>]
scope :of_type, ->(type) { where(type: Array.wrap(type).map(&:to_s)) }

# 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 evaluating conditions.
#
# @return [Array<Symbol>] An array of association names to preload
def preload_relations
[:calculator]
end

# Whether this benefit can discount the given object.
#
# Subclasses must implement this according to the kinds
# of objects they are able to discount.
#
# @param object [Object] a potential adjustable (order, line item, or shipment)
# @return [Boolean]
# @raise [NotImplementedError] when not implemented by the subclass/mixin
# @see SolidusPromotions::Benefits::OrderBenefit,
# SolidusPromotions::Benefits::LineItemBenefit,
# SolidusPromotions::Benefits::ShipmentBenefit
def can_discount?(object)
raise NotImplementedError, "Please implement the correct interface, or include one of the `SolidusPromotions::Benefits::OrderBenefit`, " \
"`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules"
Expand Down Expand Up @@ -57,12 +118,23 @@ def discount(adjustable, ...)
)
end

# Ensure a negative amount which does not exceed the object's amount
# Computes the discount amount for the given adjustable.
#
# Ensures the returned amount is negative and does not exceed the
# adjustable's discountable amount.
#
# @param adjustable [#discountable_amount] the adjustable to compute for
# @param ... [args, kwargs] additional arguments forwarded to the calculator
# @return [BigDecimal] a negative amount suitable for creating an adjustment
def compute_amount(adjustable, ...)
promotion_amount = calculator.compute(adjustable, ...) || Spree::ZERO
[adjustable.discountable_amount, promotion_amount.abs].min * -1
end

# Builds the localized label for adjustments created by this benefit.
#
# @param adjustable [Object]
# @return [String]
def adjustment_label(adjustable)
I18n.t(
"solidus_promotions.adjustment_labels.#{adjustable.class.name.demodulize.underscore}",
Expand All @@ -71,6 +143,9 @@ def adjustment_label(adjustable)
)
end

# Partial path used for admin forms for this benefit type.
#
# @return [String]
def to_partial_path
"solidus_promotions/admin/benefit_fields/#{model_name.element}"
end
Expand All @@ -80,14 +155,30 @@ def level
"`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules"
end

# Returns the set of condition classes that can still be attached to this benefit.
# Already-persisted conditions are excluded.
#
# @return [Set<Class<SolidusPromotions::Condition>>]
def available_conditions
possible_conditions - conditions.select(&:persisted?)
end

# Returns the calculators allowed for this benefit type.
#
# @return [Array<Class>] calculator classes
def available_calculators
SolidusPromotions.config.promotion_calculators[self.class] || []
end

# Verifies if the promotable satisfies all applicable conditions of this benefit.
#
# When dry_run is true, an {SolidusPromotions::EligibilityResults} entry is
# recorded for each condition with success/error details; otherwise, the
# evaluation short-circuits on the first failure.
#
# @param promotable [Object] the entity being evaluated (e.g., Spree::Order, Spree::LineItem)
# @param dry_run [Boolean] whether to collect detailed eligibility information
# @return [Boolean] true when all applicable conditions are eligible
def eligible_by_applicable_conditions?(promotable, dry_run: false)
applicable_conditions = conditions.select do |condition|
condition.applicable?(promotable)
Expand Down Expand Up @@ -116,18 +207,35 @@ def eligible_by_applicable_conditions?(promotable, dry_run: false)
end.all?
end

# All line items of the order that are eligible for this benefit.
#
# @param order [Spree::Order]
# @return [Array<Spree::LineItem>] eligible line items
def applicable_line_items(order)
order.discountable_line_items.select do |line_item|
eligible_by_applicable_conditions?(line_item)
end
end

# 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).
#
# @return [Set<Class<SolidusPromotions::Condition>>]
def possible_conditions
Set.new(SolidusPromotions.config.order_conditions)
end

private

# Prevents destroying a benefit when it has adjustments on completed orders.
#
# Adds an error and aborts the destroy callback chain when such adjustments exist.
# @api private
def raise_for_adjustments_for_completed_orders
if adjustments.joins(:order).merge(Spree::Order.complete).any?
errors.add(:base, :cannot_destroy_if_order_completed)
Expand Down
Loading