Skip to content
Merged
Show file tree
Hide file tree
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
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ def initialize(line_item)
end

def discountable_amount
return Spree::ZERO if @line_item.quantity.zero?
@line_item.discountable_amount / @line_item.quantity.to_d
end
alias_method :amount, :discountable_amount
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module SolidusPromotions
module Benefits
class AdvertisePrice < Benefit
def self.applicable_conditions
Condition.applicable_to([Spree::Order, Spree::Price])
end

def discount_price(price, ...)
discount = find_discount(price) || build_discount
discount.amount = compute_amount(price, ...)
discount.label = adjustment_label(price)
discount
end

private

def find_discount(price)
price.discounts.detect { |discount| discount.source == self }
end

def build_discount
SolidusPromotions::ItemDiscount.new(source: self)
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,35 @@ class FlatRate < Spree::Calculator
def compute_item(item)
currency = item.order.currency
if item && preferred_currency.casecmp(currency).zero?
preferred_amount
compute_for_amount(item.discountable_amount)
else
Spree::ZERO
end
end
alias_method :compute_line_item, :compute_item
alias_method :compute_shipment, :compute_item
alias_method :compute_shipping_rate, :compute_item

def compute_price(price, options = {})
order = options[:order]
quantity = options[:quantity]
return preferred_amount unless order
return Spree::ZERO if order.currency != preferred_currency
line_item_with_variant = order.line_items.detect { _1.variant == price.variant }
desired_extra_amount = quantity * price.discountable_amount
current_discounted_amount = line_item_with_variant ? line_item_with_variant.discountable_amount : Spree::ZERO
round_to_currency(
(compute_for_amount(current_discounted_amount + desired_extra_amount.to_f) -
compute_for_amount(current_discounted_amount)) / quantity,
preferred_currency
)
end

private

def compute_for_amount(amount)
[amount, preferred_amount].min
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,33 @@ class FlexiRate < Spree::Calculator
# line_item.quantity # => 5
# calculator.compute_line_item(line_item) # => 15.0 (10 + 5, limited to 2 items)
def compute_line_item(line_item)
items_count = line_item.quantity
items_count = [items_count, preferred_max_items].min unless preferred_max_items.zero?
compute_for_quantity(line_item.quantity)
end

def compute_price(price, options = {})
order = options[:order]
desired_quantity = options[:quantity] || 0
return Spree::ZERO if desired_quantity.zero?

already_ordered_quantity = if order
order.line_items.detect do |line_item|
line_item.variant == price.variant
end&.quantity || 0
else
0
end
possible_discount = compute_for_quantity(already_ordered_quantity + desired_quantity)
existing_discount = compute_for_quantity(already_ordered_quantity)
round_to_currency(
(possible_discount - existing_discount) / desired_quantity,
price.currency
)
end

private

def compute_for_quantity(quantity)
items_count = preferred_max_items.zero? ? quantity : [quantity, preferred_max_items].min

return Spree::ZERO if items_count.zero?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,19 @@ class Percent < Spree::Calculator
# calculator = Percent.new(preferred_percent: 15)
# shipment.discountable_amount # => 25.00
# calculator.compute_item(shipment) # => 3.75
def compute_item(object)
round_to_currency(object.discountable_amount * preferred_percent / 100, object.order.currency)
#
# @example Computing a 15% discount on a price
# calculator = Percent.new(preferred_percent: 15)
# price.discountable_amount # => 100.00
# calculator.compute_item(shipment) # => 15
def compute_item(object, _options = {})
currency = object.respond_to?(:currency) ? object.currency : object.order.currency
round_to_currency(object.discountable_amount * preferred_percent / 100, currency)
end
alias_method :compute_line_item, :compute_item
alias_method :compute_shipment, :compute_item
alias_method :compute_shipping_rate, :compute_item
alias_method :compute_price, :compute_item
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ def order_eligible?(order, _options = {})
def line_item_eligible?(line_item, _options = {})
LineItemOptionValue.new(preferred_eligible_values: preferred_eligible_values).eligible?(line_item)
end

def price_eligible?(price, _options = {})
PriceOptionValue.new(preferred_eligible_values: preferred_eligible_values).eligible?(price)
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

module SolidusPromotions
module Conditions
class PriceOptionValue < Condition
include OptionValueCondition

def price_eligible?(price, _options = {})
pid = price.variant.product_id
ovids = price.variant.option_value_ids

product_ids.include?(pid) && (value_ids(pid) & ovids).present?
end

private

def product_ids
preferred_eligible_values.keys
end

def value_ids(product_id)
preferred_eligible_values[product_id]
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

module SolidusPromotions
module Conditions
# A condition to apply a promotion only to prices with or without selected products
class PriceProduct < Condition
include ProductCondition

MATCH_POLICIES = %w[include exclude].freeze

preference :match_policy, :string, default: MATCH_POLICIES.first

def price_eligible?(price, _options = {})
price_matches_products = products.include?(price.variant.product)
success = exclude_configured_products? ? !price_matches_products : price_matches_products

unless success
message_code = exclude_configured_products? ? :has_excluded_product : :no_applicable_products
eligibility_errors.add(
:base,
eligibility_error_message(message_code),
error_code: message_code
)
end

success
end

private

def exclude_configured_products?
preferred_match_policy == "exclude"
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module SolidusPromotions
module Conditions
class PriceTaxon < Condition
include TaxonCondition

MATCH_POLICIES = %w[include exclude].freeze

validates :preferred_match_policy, inclusion: { in: MATCH_POLICIES }

preference :match_policy, :string, default: MATCH_POLICIES.first

def price_eligible?(price, _options = {})
price_taxon_ids = price.variant.product.classifications.map(&:taxon_id)

case preferred_match_policy
when "include"
taxon_ids_with_children.any? { |taxon_and_descendant_ids| (price_taxon_ids & taxon_and_descendant_ids).any? }
when "exclude"
taxon_ids_with_children.none? { |taxon_and_descendant_ids| (price_taxon_ids & taxon_and_descendant_ids).any? }
else
raise "unexpected match policy: #{preferred_match_policy.inspect}"
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ def line_item_eligible?(line_item, _options = {})
@eligibility_errors = line_item_condition.eligibility_errors
eligibility_errors.empty?
end

def price_eligible?(price, _options = {})
price_match_policy = preferred_match_policy.in?(%w[any all only]) ? "include" : "exclude"
price_condition = PriceProduct.new(products:, preferred_match_policy: price_match_policy)
price_condition.price_eligible?(price)
@eligibility_errors = price_condition.eligibility_errors
eligibility_errors.empty?
end
end
end
end
12 changes: 12 additions & 0 deletions promotions/app/models/solidus_promotions/conditions/taxon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ def line_item_eligible?(line_item, _options = {})
@eligibility_errors = line_item_condition.eligibility_errors
result
end

def price_eligible?(price, _options = {})
price_match_policy = preferred_match_policy.in?(%w[any all]) ? "include" : "exclude"
price_condition = PriceTaxon.new(taxons:, preferred_match_policy: price_match_policy)

# Hydrate the instance cache with our @taxon_ids_with_children cache
price_condition.taxons_ids_with_children = taxon_ids_with_children

result = price_condition.price_eligible?(price)
@eligibility_errors = price_condition.eligibility_errors
result
end
end
end
end
57 changes: 57 additions & 0 deletions promotions/app/models/solidus_promotions/product_advertiser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

module SolidusPromotions
class ProductAdvertiser
attr_reader :order, :product, :promotions, :quantity

def initialize(product:, order:, quantity: 1)
@product = product
@order = order
@quantity = quantity
@promotions = SolidusPromotions::LoadPromotions.new(order:).call
end

def call
return unless product.promotionable?

SolidusPromotions::Promotion.ordered_lanes.each do |lane|
SolidusPromotions::PromotionLane.set(current_lane: lane) do
lane_promotions = promotions.select { |promotion| promotion.lane == lane }
lane_benefits = eligible_benefits_for_promotable(lane_promotions.flat_map(&:benefits), order)

if product.has_variants?
product.variants.each { |variant| discount_variant(variant, lane_benefits) }
else
discount_variant(product.master, lane_benefits)
end
end
end
end

private

def discount_variant(variant, benefits)
variant.prices.each do |price|
next if price.discarded?
discounts = generate_discounts(benefits, price)
chosen_discounts = SolidusPromotions.config.discount_chooser_class.new(discounts).call
price.discounts.concat(chosen_discounts)
end
end

def eligible_benefits_for_promotable(possible_benefits, promotable)
possible_benefits.select do |candidate|
candidate.eligible_by_applicable_conditions?(promotable)
end
end

def generate_discounts(possible_benefits, item)
eligible_benefits = eligible_benefits_for_promotable(possible_benefits, item)
eligible_benefits.filter_map do |benefit|
next unless benefit.can_discount?(item)

benefit.discount(item, order:, quantity:)
end
end
end
end
31 changes: 31 additions & 0 deletions promotions/app/patches/models/solidus_promotions/price_patch.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

module SolidusPromotions
module PricePatch
def self.prepended(base)
base.money_methods :discounted_amount
end

def discounts
@discounts ||= []
end

attr_writer :discounts

private

# Returns discounts from specified promotion lanes.
#
# @param lanes [Array] An array of lanes to filter discounts by.
# @return [Array<SolidusPromotions::ShippingRateDiscount] An array of discounts from the
# specified lans that are not marked for destruction.
def discounts_by_lanes(lanes)
discounts.select do |discount|
discount.source.promotion.lane.in?(lanes)
end
end

Spree::Price.prepend SolidusPromotions::DiscountedAmount
Spree::Price.prepend self
end
end
Loading
Loading