diff --git a/promotions/app/models/solidus_promotions/benefit.rb b/promotions/app/models/solidus_promotions/benefit.rb index b4ca0ead7c..8d21da710e 100644 --- a/promotions/app/models/solidus_promotions/benefit.rb +++ b/promotions/app/models/solidus_promotions/benefit.rb @@ -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) + # # => # + # 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] 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] 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] 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] a single type or list of types + # @return [ActiveRecord::Relation] 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] 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" @@ -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}", @@ -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 @@ -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>] def available_conditions possible_conditions - conditions.select(&:persisted?) end + # Returns the calculators allowed for this benefit type. + # + # @return [Array] 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) @@ -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] 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>] 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)