diff --git a/core/app/models/spree/calculator.rb b/core/app/models/spree/calculator.rb index 1bee284f3c8..8b20de0f810 100644 --- a/core/app/models/spree/calculator.rb +++ b/core/app/models/spree/calculator.rb @@ -9,13 +9,13 @@ class Calculator < Spree::Base # This method calls a compute_ method. must be overriden in concrete calculator. # # It should return amount computed based on #calculable and the computable parameter - def compute(computable) + def compute(computable, ...) # Spree::LineItem -> :compute_line_item computable_name = computable.class.name.demodulize.underscore - method_name = "compute_#{computable_name}".to_sym + method_name = :"compute_#{computable_name}" calculator_class = self.class if respond_to?(method_name) - send(method_name, computable) + send(method_name, computable, ...) else raise NotImplementedError, "Please implement '#{method_name}(#{computable_name})' in your calculator: #{calculator_class.name}" end diff --git a/core/spec/models/spree/calculator_spec.rb b/core/spec/models/spree/calculator_spec.rb index 554689d7fc5..12a57099879 100644 --- a/core/spec/models/spree/calculator_spec.rb +++ b/core/spec/models/spree/calculator_spec.rb @@ -9,7 +9,7 @@ def self.name "SimpleCalculator" end - def compute_simple_computable(_) + def compute_simple_computable(_, _options = {}) "computed" end end @@ -53,5 +53,15 @@ def self.name expect { subject }.to raise_error NotImplementedError, /Please implement \'compute_line_item\(line_item\)\' in your calculator/ end end + + context "with options" do + let(:order) { double(Spree::Order) } + subject { calculator.compute(computable, order: order) } + + it "passes the options to compute_simple_computable" do + expect(calculator).to receive(:compute_simple_computable).with(computable, order: order) + subject + end + end end end diff --git a/promotions/app/models/concerns/solidus_promotions/adjusted_amount_by_lane.rb b/promotions/app/models/concerns/solidus_promotions/adjusted_amount_by_lane.rb new file mode 100644 index 00000000000..bf3cd78c8e5 --- /dev/null +++ b/promotions/app/models/concerns/solidus_promotions/adjusted_amount_by_lane.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SolidusPromotions + module AdjustedAmountByLane + def adjusted_amount_by_lanes(lanes) + amount + adjustment_amount_by_lanes(lanes) + end + + def adjustment_amount_by_lanes(lanes) + adjustments.select do |adjustment| + adjustment.promotion? && adjustment.source.promotion.lane.in?(lanes) + end.sum(&:amount) + end + end +end diff --git a/promotions/app/models/concerns/solidus_promotions/calculators/promotion_calculator.rb b/promotions/app/models/concerns/solidus_promotions/calculators/promotion_calculator.rb index de05ddc4861..02dfd066d42 100644 --- a/promotions/app/models/concerns/solidus_promotions/calculators/promotion_calculator.rb +++ b/promotions/app/models/concerns/solidus_promotions/calculators/promotion_calculator.rb @@ -13,6 +13,12 @@ def round_to_currency(number, currency) currency_exponent = ::Money::Currency.find(currency).exponent number.round(currency_exponent) end + + def adjusted_amount_before_current_lane(item) + item.adjusted_amount_by_lanes(promotion.previous_lanes) + end + + delegate :promotion, to: :calculable end end end diff --git a/promotions/app/models/solidus_promotions/benefit.rb b/promotions/app/models/solidus_promotions/benefit.rb index fe04a9ed8fc..e4d3a10bc27 100644 --- a/promotions/app/models/solidus_promotions/benefit.rb +++ b/promotions/app/models/solidus_promotions/benefit.rb @@ -29,8 +29,8 @@ def can_discount?(object) "`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules" end - def discount(adjustable) - amount = compute_amount(adjustable) + def discount(adjustable, ...) + amount = compute_amount(adjustable, ...) return if amount.zero? ItemDiscount.new( item: adjustable, @@ -41,8 +41,8 @@ def discount(adjustable) end # Ensure a negative amount which does not exceed the object's amount - def compute_amount(adjustable) - promotion_amount = calculator.compute(adjustable) || Spree::ZERO + def compute_amount(adjustable, ...) + promotion_amount = calculator.compute(adjustable, ...) || Spree::ZERO [adjustable.discountable_amount, promotion_amount.abs].min * -1 end diff --git a/promotions/app/models/solidus_promotions/benefits/adjust_line_item_quantity_groups.rb b/promotions/app/models/solidus_promotions/benefits/adjust_line_item_quantity_groups.rb index a0140c3bd08..8ea51afeb6e 100644 --- a/promotions/app/models/solidus_promotions/benefits/adjust_line_item_quantity_groups.rb +++ b/promotions/app/models/solidus_promotions/benefits/adjust_line_item_quantity_groups.rb @@ -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 diff --git a/promotions/app/models/solidus_promotions/benefits/adjust_price.rb b/promotions/app/models/solidus_promotions/benefits/adjust_price.rb new file mode 100644 index 00000000000..dcd615cc150 --- /dev/null +++ b/promotions/app/models/solidus_promotions/benefits/adjust_price.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Benefits + class AdjustPrice < Benefit + def can_discount?(object) + object.is_a?(Spree::LineItem) || object.is_a?(Spree::Price) + end + + def possible_conditions + super + SolidusPromotions.config.price_conditions + end + + def level + :line_item + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/calculators/flat_rate.rb b/promotions/app/models/solidus_promotions/calculators/flat_rate.rb index 627e6ee5b31..7dd6662aa97 100644 --- a/promotions/app/models/solidus_promotions/calculators/flat_rate.rb +++ b/promotions/app/models/solidus_promotions/calculators/flat_rate.rb @@ -7,17 +7,41 @@ module Calculators class FlatRate < Spree::Calculator include PromotionCalculator - preference :amount, :decimal, default: 0 + preference :amount, :decimal, default: Spree::ZERO preference :currency, :string, default: -> { Spree::Config[:currency] } - def compute(object = nil) - currency = object.order.currency - if object && preferred_currency.casecmp(currency).zero? - preferred_amount + def compute_item(item) + currency = item.order.currency + if item && preferred_currency.casecmp(currency).zero? + compute_for_amount(item.discountable_amount) else - 0 + 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 ? adjusted_amount_before_current_lane(line_item_with_variant) : 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 diff --git a/promotions/app/models/solidus_promotions/calculators/flexi_rate.rb b/promotions/app/models/solidus_promotions/calculators/flexi_rate.rb index 971b8dd6c03..200d7d5951f 100644 --- a/promotions/app/models/solidus_promotions/calculators/flexi_rate.rb +++ b/promotions/app/models/solidus_promotions/calculators/flexi_rate.rb @@ -12,9 +12,34 @@ class FlexiRate < Spree::Calculator preference :max_items, :integer, default: 0 preference :currency, :string, default: -> { Spree::Config[:currency] } - def compute(object) - items_count = object.quantity - items_count = [items_count, preferred_max_items].min unless preferred_max_items.zero? + def compute_line_item(line_item) + 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 == 0 diff --git a/promotions/app/models/solidus_promotions/calculators/percent.rb b/promotions/app/models/solidus_promotions/calculators/percent.rb index 8f3c799f9f2..aed3532614c 100644 --- a/promotions/app/models/solidus_promotions/calculators/percent.rb +++ b/promotions/app/models/solidus_promotions/calculators/percent.rb @@ -9,8 +9,21 @@ class Percent < Spree::Calculator preference :percent, :decimal, default: 0 - def compute(object) - round_to_currency(object.discountable_amount * preferred_percent / 100, object.order.currency) + def compute_item(item) + compute_with_currency(item, item.order.currency) + 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 = {}) + compute_with_currency(price, price.currency) + end + + private + + def compute_with_currency(item, currency) + round_to_currency(item.discountable_amount * preferred_percent / 100, currency) end end end diff --git a/promotions/app/models/solidus_promotions/conditions/line_item_option_value.rb b/promotions/app/models/solidus_promotions/conditions/line_item_option_value.rb index c548b8b5311..d9dca575dbf 100644 --- a/promotions/app/models/solidus_promotions/conditions/line_item_option_value.rb +++ b/promotions/app/models/solidus_promotions/conditions/line_item_option_value.rb @@ -7,9 +7,9 @@ class LineItemOptionValue < Condition preference :eligible_values, :hash - def eligible?(line_item, _options = {}) - pid = line_item.product.id - ovids = line_item.variant.option_values.pluck(:id) + def eligible?(line_item_or_price, _options = {}) + pid = line_item_or_price.variant.product_id + ovids = line_item_or_price.variant.option_values.pluck(:id) product_ids.include?(pid) && (value_ids(pid) & ovids).present? end diff --git a/promotions/app/models/solidus_promotions/conditions/line_item_product.rb b/promotions/app/models/solidus_promotions/conditions/line_item_product.rb index 6ad44d3e606..db43fb0afc8 100644 --- a/promotions/app/models/solidus_promotions/conditions/line_item_product.rb +++ b/promotions/app/models/solidus_promotions/conditions/line_item_product.rb @@ -23,9 +23,9 @@ def preload_relations [:products] end - def eligible?(line_item, _options = {}) - order_includes_product = product_ids.include?(line_item.variant.product_id) - success = inverse? ? !order_includes_product : order_includes_product + def eligible?(line_item_or_price, _options = {}) + item_matches_product = product_ids.include?(line_item_or_price.variant.product_id) + success = inverse? ? !item_matches_product : item_matches_product unless success message_code = inverse? ? :has_excluded_product : :no_applicable_products diff --git a/promotions/app/models/solidus_promotions/conditions/line_item_taxon.rb b/promotions/app/models/solidus_promotions/conditions/line_item_taxon.rb index 3956512060f..0efee4aeefe 100644 --- a/promotions/app/models/solidus_promotions/conditions/line_item_taxon.rb +++ b/promotions/app/models/solidus_promotions/conditions/line_item_taxon.rb @@ -22,9 +22,9 @@ def preload_relations [:taxons] end - def eligible?(line_item, _options = {}) + def eligible?(line_item_or_price, _options = {}) found = Spree::Classification.where( - product_id: line_item.variant.product_id, + product_id: line_item_or_price.variant.product_id, taxon_id: condition_taxon_ids_with_children ).exists? diff --git a/promotions/app/models/solidus_promotions/product_discounter.rb b/promotions/app/models/solidus_promotions/product_discounter.rb new file mode 100644 index 00000000000..08073c0b789 --- /dev/null +++ b/promotions/app/models/solidus_promotions/product_discounter.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module SolidusPromotions + class ProductDiscounter + attr_reader :order, :product, :pricing_options, :promotions, :quantity + + def initialize(product:, order:, pricing_options:, quantity: 1) + @product = product + @order = order + @pricing_options = pricing_options + @quantity = quantity + @promotions = SolidusPromotions::LoadPromotions.new(order:).call + end + + def call + if product.has_variants? + product.variants.each { |variant| discount_variant(variant) } + else + discount_variant(product.master) + end + end + + private + + def discount_variant(variant) + variant.discountable_price = variant.price_for_options(pricing_options) + + return unless variant.product.promotionable? + + SolidusPromotions::Promotion.ordered_lanes.each_key do |lane| + lane_promotions = promotions.select { |promotion| promotion.lane == lane } + lane_benefits = eligible_benefits_for_promotable(lane_promotions.flat_map(&:benefits), order) + discounts = generate_discounts(lane_benefits, variant.discountable_price) + chosen_discounts = SolidusPromotions.config.discount_chooser_class.new(discounts).call + variant.discountable_price.current_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.select do |benefit| + benefit.can_discount?(item) + end.map do |benefit| + benefit.discount(item, order:, quantity:) + end.compact + end + end +end diff --git a/promotions/app/models/solidus_promotions/promotion.rb b/promotions/app/models/solidus_promotions/promotion.rb index 03a9c145601..5f7574ada19 100644 --- a/promotions/app/models/solidus_promotions/promotion.rb +++ b/promotions/app/models/solidus_promotions/promotion.rb @@ -119,6 +119,13 @@ def usage_limit_exceeded?(excluded_orders: []) usage_count(excluded_orders: excluded_orders) >= usage_limit end + def previous_lanes + current_lane_index = self.class.ordered_lanes.detect { |name, _index| lane == name }.last + self.class.ordered_lanes.filter_map do |name, index| + name if index < current_lane_index + end + end + def not_expired?(time = Time.current) !expired?(time) end diff --git a/promotions/app/patches/models/solidus_promotions/line_item_patch.rb b/promotions/app/patches/models/solidus_promotions/line_item_patch.rb index 1e79d97009e..fe8dbf2d62c 100644 --- a/promotions/app/patches/models/solidus_promotions/line_item_patch.rb +++ b/promotions/app/patches/models/solidus_promotions/line_item_patch.rb @@ -23,5 +23,6 @@ def reset_quantity_setter Spree::LineItem.prepend self Spree::LineItem.prepend SolidusPromotions::DiscountableAmount + Spree::LineItem.prepend SolidusPromotions::AdjustedAmountByLane end end diff --git a/promotions/app/patches/models/solidus_promotions/price_patch.rb b/promotions/app/patches/models/solidus_promotions/price_patch.rb new file mode 100644 index 00000000000..6010ce2a107 --- /dev/null +++ b/promotions/app/patches/models/solidus_promotions/price_patch.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module SolidusPromotions + module PricePatch + def self.prepended(base) + base.alias_method :discounted_amount, :discountable_amount + base.alias_method :discounts, :current_discounts + base.money_methods :discounted_amount + end + + Spree::Price.prepend SolidusPromotions::DiscountableAmount + Spree::Price.prepend self + end +end diff --git a/promotions/app/patches/models/solidus_promotions/product_patch.rb b/promotions/app/patches/models/solidus_promotions/product_patch.rb new file mode 100644 index 00000000000..bc322a37f99 --- /dev/null +++ b/promotions/app/patches/models/solidus_promotions/product_patch.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module SolidusPromotions + module ProductPatch + extend ActiveSupport::Concern + + def self.prepended(base) + base.delegate :discounted_price, :undiscounted_price, :price_discounts, to: :master + end + Spree::Product.prepend self + end +end diff --git a/promotions/app/patches/models/solidus_promotions/shipment_patch.rb b/promotions/app/patches/models/solidus_promotions/shipment_patch.rb index c73b14f7da1..9b0ae7b74a0 100644 --- a/promotions/app/patches/models/solidus_promotions/shipment_patch.rb +++ b/promotions/app/patches/models/solidus_promotions/shipment_patch.rb @@ -10,5 +10,6 @@ def reset_current_discounts end Spree::Shipment.prepend self + Spree::Shipment.prepend SolidusPromotions::AdjustedAmountByLane end end diff --git a/promotions/app/patches/models/solidus_promotions/variant_patch.rb b/promotions/app/patches/models/solidus_promotions/variant_patch.rb new file mode 100644 index 00000000000..beea688264c --- /dev/null +++ b/promotions/app/patches/models/solidus_promotions/variant_patch.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module SolidusPromotions + module VariantPatch + class VariantNotDiscounted < StandardError; end + attr_accessor :discountable_price + + def self.prepended(base) + base.extend Spree::DisplayMoney + + base.money_methods :discounted_price, :undiscounted_price + end + + def undiscounted_price + raise VariantNotDiscounted unless discountable_price + discountable_price.amount + end + + def discounted_price + raise VariantNotDiscounted unless discountable_price + discountable_price.discountable_amount + end + + def price_discounts + raise VariantNotDiscounted unless discountable_price + discountable_price.current_discounts + end + + Spree::Variant.prepend self + end +end diff --git a/promotions/config/locales/en.yml b/promotions/config/locales/en.yml index a1a18cdbba7..ae16b3f93ce 100644 --- a/promotions/config/locales/en.yml +++ b/promotions/config/locales/en.yml @@ -27,6 +27,7 @@ en: line_item: "%{promotion} (%{promotion_customer_label})" shipment: "%{promotion} (%{promotion_customer_label})" shipping_rate: "%{promotion} (%{promotion_customer_label})" + price: "%{promotion} (%{promotion_customer_label})" adjustment_type: Adjustment type add_benefit: Add Benefit add_condition: Add Condition @@ -191,6 +192,7 @@ en: models: solidus_promotions/benefits/adjust_shipment: Discount matching shipments solidus_promotions/benefits/adjust_line_item: Discount matching line items + solidus_promotions/benefits/adjust_price: Discount prices and matching line items solidus_promotions/benefits/create_discounted_item: Create discounted line item solidus_promotions/benefits/adjust_line_item_quantity_groups: Discount matching line items based on quantity groups solidus_promotions/calculators/distributed_amount: Distributed Amount diff --git a/promotions/lib/solidus_promotions/configuration.rb b/promotions/lib/solidus_promotions/configuration.rb index 05d9503344c..0ceb5923388 100644 --- a/promotions/lib/solidus_promotions/configuration.rb +++ b/promotions/lib/solidus_promotions/configuration.rb @@ -49,6 +49,12 @@ class Configuration < Spree::Preferences::Configuration "SolidusPromotions::Conditions::ShippingMethod" ] + add_class_set :price_conditions, default: [ + "SolidusPromotions::Conditions::LineItemProduct", + "SolidusPromotions::Conditions::LineItemTaxon", + "SolidusPromotions::Conditions::LineItemOptionValue" + ] + add_class_set :benefits, default: [ "SolidusPromotions::Benefits::AdjustLineItem", "SolidusPromotions::Benefits::AdjustLineItemQuantityGroups", @@ -74,6 +80,11 @@ class Configuration < Spree::Preferences::Configuration "SolidusPromotions::Calculators::TieredPercent", "SolidusPromotions::Calculators::TieredPercentOnEligibleItemQuantity" ], + "SolidusPromotions::Benefits::AdjustPrice" => [ + "SolidusPromotions::Calculators::FlatRate", + "SolidusPromotions::Calculators::Percent", + "SolidusPromotions::Calculators::FlexiRate" + ], "SolidusPromotions::Benefits::AdjustLineItemQuantityGroups" => [ "SolidusPromotions::Calculators::FlatRate", "SolidusPromotions::Calculators::Percent", diff --git a/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_price.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_price.html.erb new file mode 100644 index 00000000000..924939b992c --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_price.html.erb @@ -0,0 +1,6 @@ +<%= render( + "solidus_promotions/admin/benefit_fields/calculator_fields", + benefit: benefit, + param_prefix: param_prefix, + form: form +) %> diff --git a/promotions/spec/lib/solidus_promotions/configuration_spec.rb b/promotions/spec/lib/solidus_promotions/configuration_spec.rb index de82e68f8c1..4fe6dc28405 100644 --- a/promotions/spec/lib/solidus_promotions/configuration_spec.rb +++ b/promotions/spec/lib/solidus_promotions/configuration_spec.rb @@ -49,6 +49,12 @@ it { is_expected.to be_a(Spree::Core::ClassConstantizer::Set) } end + describe ".price_conditions" do + subject { config.price_conditions } + + it { is_expected.to be_a(Spree::Core::ClassConstantizer::Set) } + end + describe ".sync_order_promotions" do subject { config.sync_order_promotions } diff --git a/promotions/spec/models/solidus_promotions/benefit_spec.rb b/promotions/spec/models/solidus_promotions/benefit_spec.rb index 3c8f5503f8c..d4ac66685d2 100644 --- a/promotions/spec/models/solidus_promotions/benefit_spec.rb +++ b/promotions/spec/models/solidus_promotions/benefit_spec.rb @@ -104,6 +104,24 @@ expect(subject).to be nil end end + + context "if passing in extra options" do + let(:calculator_class) do + Class.new(Spree::Calculator) do + def compute_price(price, options) + end + end + end + let(:calculator) { calculator_class.new } + let(:order) { double(Spree::Order) } + let(:discountable) { build(:price) } + + subject { benefit.discount(discountable, order:) } + it "passes the option on to the calculator" do + expect(calculator).to receive(:compute_price).with(discountable, order:).and_return(1) + subject + end + end end describe ".original_promotion_action" do diff --git a/promotions/spec/models/solidus_promotions/benefits/adjust_price_spec.rb b/promotions/spec/models/solidus_promotions/benefits/adjust_price_spec.rb new file mode 100644 index 00000000000..07d2926a5c4 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/benefits/adjust_price_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Benefits::AdjustPrice do + subject(:benefit) { described_class.new } + + describe "name" do + subject(:name) { benefit.model_name.human } + + it { is_expected.to eq("Discount prices and matching line items") } + end + + describe "#can_discount?" do + subject { benefit.can_discount?(discountable) } + + context "if discountable is a Spree::Price" do + let(:discountable) { Spree::Price.new } + + it { is_expected.to be true } + end + + context "if discountable is a Spree::LineItem" do + let(:discountable) { Spree::LineItem.new } + + it { is_expected.to be true } + end + end + + describe "#possible_conditions" do + subject { benefit.possible_conditions } + + it { is_expected.to include(*SolidusPromotions.config.price_conditions) } + it { is_expected.to include(*SolidusPromotions.config.order_conditions) } + end + + describe ".to_partial_path" do + subject { described_class.new.to_partial_path } + + it { is_expected.to eq("solidus_promotions/admin/benefit_fields/adjust_price") } + end + + describe "#level" do + subject { described_class.new.level } + + it { is_expected.to eq(:line_item) } + end +end diff --git a/promotions/spec/models/solidus_promotions/calculators/flat_rate_spec.rb b/promotions/spec/models/solidus_promotions/calculators/flat_rate_spec.rb index fba69bacf17..82730a695bc 100644 --- a/promotions/spec/models/solidus_promotions/calculators/flat_rate_spec.rb +++ b/promotions/spec/models/solidus_promotions/calculators/flat_rate_spec.rb @@ -4,12 +4,14 @@ require "shared_examples/calculator_shared_examples" RSpec.describe SolidusPromotions::Calculators::FlatRate, type: :model do - subject { calculator.compute(line_item) } + subject { calculator.compute(discountable) } - let(:line_item) { mock_model(Spree::LineItem, order: order) } let(:order) { mock_model(Spree::Order, currency: order_currency) } + let(:promotion) { build_stubbed(:solidus_promotion) } + let(:benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(promotion: promotion) } let(:calculator) do described_class.new( + calculable: benefit, preferred_amount: preferred_amount, preferred_currency: preferred_currency ) @@ -17,7 +19,9 @@ it_behaves_like "a calculator with a description" - context "compute" do + context "compute_line_item" do + let(:discountable) { mock_model(Spree::LineItem, order: order, discountable_amount: 100) } + describe "when preferred currency matches order" do let(:preferred_currency) { "GBP" } let(:order_currency) { "GBP" } @@ -50,4 +54,76 @@ it { is_expected.to eq(25.0) } end end + + context "compute_shipment" do + let(:discountable) { mock_model(Spree::Shipment, order: order, discountable_amount: 100) } + describe "when preferred currency matches order" do + let(:preferred_currency) { "GBP" } + let(:order_currency) { "GBP" } + let(:preferred_amount) { 25 } + + it { is_expected.to eq(25.0) } + end + end + + context "compute_shipping_rate" do + let(:discountable) { mock_model(Spree::ShippingRate, order: order, discountable_amount: 100) } + describe "when preferred currency matches order" do + let(:preferred_currency) { "GBP" } + let(:order_currency) { "GBP" } + let(:preferred_amount) { 25 } + + it { is_expected.to eq(25.0) } + end + end + + describe "compute_price" do + let(:preferred_currency) { "GBP" } + let(:order_currency) { "GBP" } + let(:preferred_amount) { 25 } + + let(:discountable) { mock_model(Spree::Price, amount: price_amount, variant: variant, discountable_amount: price_amount) } + let(:variant) { build(:variant) } + let(:price_amount) { 20 } + let(:line_item) { Spree::LineItem.new(variant:, quantity:, price: 20) } + let(:other_variant) { build(:variant) } + let(:other_line_item) { Spree::LineItem.new(variant: other_variant) } + let(:quantity) { 0 } + let(:desired_quantity) { 1 } + let(:order) { mock_model(Spree::Order, line_items: [line_item, other_line_item], currency: order_currency) } + + subject { calculator.compute(discountable, { order: order, quantity: desired_quantity }) } + + it { is_expected.to eq(20) } + + context "with discounted line item present" do + let(:quantity) { 1 } + it { is_expected.to eq(5) } + end + + context "when desiring 2" do + let(:desired_quantity) { 2 } + it { is_expected.to eq(12.5) } + end + + context "with discounted line item present that takes up the whole amount" do + let(:quantity) { 2 } + + it { is_expected.to eq(0) } + end + + context "with order currency different" do + let(:quantity) { 1 } + let(:order_currency) { "USD" } + + it { is_expected.to eq(0) } + end + + context "if order is not given" do + let(:order) { nil } + let(:quantity) { 1 } + + it { is_expected.to eq(25) } + end + end end diff --git a/promotions/spec/models/solidus_promotions/calculators/flexi_rate_spec.rb b/promotions/spec/models/solidus_promotions/calculators/flexi_rate_spec.rb index d8112638378..b295ab2fef2 100644 --- a/promotions/spec/models/solidus_promotions/calculators/flexi_rate_spec.rb +++ b/promotions/spec/models/solidus_promotions/calculators/flexi_rate_spec.rb @@ -11,24 +11,20 @@ preferred_max_items: max_items ) end - let(:line_item) do - mock_model( - Spree::LineItem, quantity: quantity - ) - end + let(:first_item) { 0 } let(:additional_item) { 0 } let(:max_items) { 0 } - let(:line_item) do - mock_model( - Spree::LineItem, quantity: quantity - ) - end - it_behaves_like "a calculator with a description" - context "compute" do + context "compute_line_item" do + let(:line_item) do + mock_model( + Spree::LineItem, quantity: quantity + ) + end + subject { calculator.compute(line_item) } context "with all amounts 0" do @@ -177,6 +173,183 @@ end end + context "compute_price" do + let(:variant) { mock_model(Spree::Variant) } + let(:price) { mock_model(Spree::Price, amount: 12, variant: variant, currency: "USD") } + let(:order) { mock_model(Spree::Order, line_items: [line_item]) } + let(:line_item_quantity) { 0 } + let(:line_item) do + mock_model( + Spree::LineItem, + quantity: line_item_quantity, + variant: variant + ) + end + + subject { calculator.compute(price, { order: order, quantity: quantity }) } + + context "with no order given" do + let(:order) { nil } + + context "when first_item and additional_item have values" do + let(:first_item) { 1.13 } + let(:additional_item) { 2.11 } + + context "with quantity 2" do + let(:quantity) { 2 } + + it { is_expected.to eq(1.62) } + end + end + end + + context "if nothing is in the cart" do + let(:line_item_quantity) { 0 } + + context "when first_item and additional_items have values" do + let(:first_item) { 1.13 } + let(:additional_item) { 2.11 } + + context "with quantity 0" do + let(:quantity) { 0 } + + it { is_expected.to eq 0 } + end + + context "with quantity 1" do + let(:quantity) { 1 } + + it { is_expected.to eq 1.13 } + end + + context "with quantity 2" do + let(:quantity) { 2 } + + it { is_expected.to eq 1.62 } + end + + context "with quantity 10" do + let(:quantity) { 3 } + + it { is_expected.to eq 1.78 } + end + + context "with quantity 10" do + let(:quantity) { 10 } + + it { is_expected.to eq 2.01 } + end + + context "with max_items 5" do + let(:max_items) { 5 } + + context "with quantity 0" do + let(:quantity) { 0 } + + it { is_expected.to eq 0 } + end + + context "with quantity 1" do + let(:quantity) { 1 } + + it { is_expected.to eq 1.13 } + end + + context "with quantity 2" do + let(:quantity) { 2 } + + it { is_expected.to eq 1.62 } + end + + context "with quantity 5" do + let(:quantity) { 5 } + + it { is_expected.to eq 1.91 } + end + + context "with quantity 10" do + let(:quantity) { 10 } + + it { is_expected.to eq 0.96 } + end + end + end + end + + context "with items already in the cart" do + let(:line_item_quantity) { 2 } + + context "when first_item and additional_items have values" do + let(:first_item) { 1.13 } + let(:additional_item) { 2.11 } + + context "with quantity 0" do + let(:quantity) { 0 } + + it { is_expected.to eq 0 } + end + + context "with quantity 1" do + let(:quantity) { 1 } + + it { is_expected.to eq 2.11 } + end + + context "with quantity 2" do + let(:quantity) { 2 } + + it { is_expected.to eq 2.11 } + end + + context "with quantity 10" do + let(:quantity) { 3 } + + it { is_expected.to eq 2.11 } + end + + context "with quantity 10" do + let(:quantity) { 10 } + + it { is_expected.to eq 2.11 } + end + + context "with max_items 5" do + let(:max_items) { 5 } + + context "with quantity 0" do + let(:quantity) { 0 } + + it { is_expected.to eq 0 } + end + + context "with quantity 1" do + let(:quantity) { 1 } + + it { is_expected.to eq 2.11 } + end + + context "with quantity 2" do + let(:quantity) { 2 } + + it { is_expected.to eq 2.11 } + end + + context "with quantity 5" do + let(:quantity) { 5 } + + it { is_expected.to eq 1.27 } + end + + context "with quantity 10" do + let(:quantity) { 10 } + + it { is_expected.to eq 0.63 } + end + end + end + end + end + it "allows creation of new object with all the attributes" do attributes = { preferred_first_item: 1, preferred_additional_item: 1, preferred_max_items: 1 } calculator = described_class.new(attributes) diff --git a/promotions/spec/models/solidus_promotions/calculators/percent_spec.rb b/promotions/spec/models/solidus_promotions/calculators/percent_spec.rb index aa03277db72..498a94b3145 100644 --- a/promotions/spec/models/solidus_promotions/calculators/percent_spec.rb +++ b/promotions/spec/models/solidus_promotions/calculators/percent_spec.rb @@ -4,10 +4,13 @@ require "shared_examples/calculator_shared_examples" RSpec.describe SolidusPromotions::Calculators::Percent, type: :model do - context "compute" do - let(:currency) { "USD" } - let(:order) { double(currency: currency) } - let(:line_item) { double("Spree::LineItem", discountable_amount: 100, order: order) } + it_behaves_like "a calculator with a description" + + let(:currency) { "USD" } + let(:order) { double(currency: currency) } + + context "compute_line_item" do + let(:line_item) { mock_model("Spree::LineItem", discountable_amount: 100, order: order) } before { subject.preferred_percent = 15 } @@ -16,5 +19,23 @@ end end - it_behaves_like "a calculator with a description" + describe "compute_shipment" do + let(:shipment) { mock_model(Spree::Shipment, amount: 110, discountable_amount: 100, order: order) } + + before { subject.preferred_percent = 15 } + + it "computes based on item price and quantity" do + expect(subject.compute(shipment)).to eq 15 + end + end + + describe "compute_price" do + let(:price) { mock_model(Spree::Price, amount: 110, discountable_amount: 100, currency: "USD") } + + before { subject.preferred_percent = 15 } + + it "computes based on item price and quantity" do + expect(subject.compute(price, { order: order })).to eq 15 + end + end end diff --git a/promotions/spec/models/solidus_promotions/conditions/line_item_option_value_spec.rb b/promotions/spec/models/solidus_promotions/conditions/line_item_option_value_spec.rb new file mode 100644 index 00000000000..c8faf08bba2 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/line_item_option_value_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::LineItemOptionValue do + let!(:eligible_product) { create(:product) } + let!(:eligible_variant) { create(:variant, product: eligible_product) } + let!(:ineligible_variant) { create(:variant, product: eligible_product) } + let!(:ineligible_product) { create(:product) } + let!(:condition) do + described_class.new( + preferred_eligible_values: { + eligible_product.id => eligible_variant.option_value_ids + } + ) + end + + subject { condition.eligible?(discountable) } + + context "with a Spree::LineItem" do + let(:discountable) { build_stubbed(:line_item, variant: variant) } + let(:variant) { eligible_variant } + + it { is_expected.to be true } + + context "if the variant is ineligible but with the same product" do + let(:variant) { ineligible_variant } + + it { is_expected.to be false } + end + + context "if the variant is ineligible because the product is wrong" do + let(:variant) { ineligible_product.master } + + it { is_expected.to be false } + end + end + + context "with a Spree::Price" do + let(:discountable) { build_stubbed(:price, variant: variant) } + let(:variant) { eligible_variant } + + it { is_expected.to be true } + + context "if the variant is ineligible but with the same product" do + let(:variant) { ineligible_variant } + + it { is_expected.to be false } + end + + context "if the variant is ineligible because the product is wrong" do + let(:variant) { ineligible_product.master } + + it { is_expected.to be false } + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/line_item_product_spec.rb b/promotions/spec/models/solidus_promotions/conditions/line_item_product_spec.rb index 86779ad9e8b..ff18dd8908b 100644 --- a/promotions/spec/models/solidus_promotions/conditions/line_item_product_spec.rb +++ b/promotions/spec/models/solidus_promotions/conditions/line_item_product_spec.rb @@ -78,6 +78,73 @@ end end + describe "#eligible?(price)" do + subject { condition.eligible?(price, {}) } + + let(:condition_price) { Spree::Price.new(variant: Spree::Variant.new(product: condition_product)) } + let(:other_price) { Spree::Price.new(variant: Spree::Variant.new(product: other_product)) } + + let(:condition_options) { super().merge(products: [condition_product]) } + let(:condition_product) { mock_model(Spree::Product) } + let(:other_product) { mock_model(Spree::Product) } + + it "is eligible if there are no products" do + expect(condition).to be_eligible(condition_price) + end + + context "for product in condition" do + let(:price) { condition_price } + + it { is_expected.to be_truthy } + + it "has no error message" do + subject + expect(condition.eligibility_errors.full_messages).to be_empty + end + end + + context "for product not in condition" do + let(:price) { other_price } + + it { is_expected.to be_falsey } + + it "has the right error message" do + subject + expect(condition.eligibility_errors.full_messages.first).to eq( + "You need to add an applicable product before applying this coupon code." + ) + end + end + + context "if match policy is inverse" do + let(:condition_options) { super().merge(preferred_match_policy: "exclude") } + + context "for product in condition" do + let(:price) { condition_price } + + it { is_expected.to be_falsey } + + it "has the right error message" do + subject + expect(condition.eligibility_errors.full_messages.first).to eq( + "Your cart contains a product that prevents this coupon code from being applied." + ) + end + end + + context "for product not in condition" do + let(:price) { other_price } + + it { is_expected.to be_truthy } + + it "has no error message" do + subject + expect(condition.eligibility_errors.full_messages).to be_empty + end + end + end + end + describe "#preload_relations" do subject { condition.preload_relations } it { is_expected.to eq([:products]) } diff --git a/promotions/spec/models/solidus_promotions/conditions/line_item_taxon_spec.rb b/promotions/spec/models/solidus_promotions/conditions/line_item_taxon_spec.rb index 34c2515f497..74865fe3aff 100644 --- a/promotions/spec/models/solidus_promotions/conditions/line_item_taxon_spec.rb +++ b/promotions/spec/models/solidus_promotions/conditions/line_item_taxon_spec.rb @@ -21,6 +21,84 @@ it { is_expected.to eq([:taxons]) } end + describe "#eligible?(price)" do + let(:price) { create(:price, variant: order.line_items.first.variant) } + let(:order) { create :order_with_line_items } + let(:taxon) { create :taxon, name: "first" } + + context "with an invalid match policy" do + before do + condition.preferred_match_policy = "invalid" + condition.save!(validate: false) + price.product.taxons << taxon + condition.taxons << taxon + end + + it "raises" do + expect { + condition.eligible?(price) + }.to raise_error('unexpected match policy: "invalid"') + end + end + + context "when a product has a taxon of a taxon condition" do + before do + product.taxons << taxon + condition.taxons << taxon + condition.save! + end + + it "is eligible" do + expect(condition).to be_eligible(price) + end + end + + context "when a product has a taxon child of a taxon condition" do + before do + taxon.children << taxon2 + product.taxons << taxon2 + condition.taxons << taxon + condition.save! + end + + it "is eligible" do + expect(condition).to be_eligible(price) + end + + context "with 'exclude' match policy" do + before do + condition.update(preferred_match_policy: :exclude) + end + + it "is not eligible" do + expect(condition).not_to be_eligible(price) + end + end + end + + context "when a product does not have taxon or child taxon of a taxon condition" do + before do + product.taxons << taxon2 + condition.taxons << taxon + condition.save! + end + + it "is not eligible" do + expect(condition).not_to be_eligible(price) + end + + context "with 'exclude' match policy" do + before do + condition.update(preferred_match_policy: :exclude) + end + + it "is not eligible" do + expect(condition).to be_eligible(price) + end + end + end + end + describe "#eligible?" do let(:line_item) { order.line_items.first! } let(:order) { create :order_with_line_items } diff --git a/promotions/spec/models/solidus_promotions/product_discounter_spec.rb b/promotions/spec/models/solidus_promotions/product_discounter_spec.rb new file mode 100644 index 00000000000..707911d7d16 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/product_discounter_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::ProductDiscounter do + subject do + described_class.new(product:, order:, pricing_options:).call + end + + let(:product) { create(:product) } + let(:order) { create(:order) } + let(:pricing_options) do + Spree::Variant::PricingOptions.new + end + + let!(:promotion) { create(:solidus_promotion, :with_adjustable_benefit, promotion_benefit_class: SolidusPromotions::Benefits::AdjustPrice, apply_automatically: true) } + + it "applies a discount to the product's price" do + expect { product.discounted_price }.to raise_exception(SolidusPromotions::VariantPatch::VariantNotDiscounted) + subject + # Standard benefit calculator is a flat 10 USD off + expect(product.discounted_price).to eq(9.99) + expect(product.price_discounts.map(&:label)).to include("Promotion (Because we like you)") + end +end diff --git a/promotions/spec/models/solidus_promotions/promotion_spec.rb b/promotions/spec/models/solidus_promotions/promotion_spec.rb index 6d941eabf6b..103cd6fead1 100644 --- a/promotions/spec/models/solidus_promotions/promotion_spec.rb +++ b/promotions/spec/models/solidus_promotions/promotion_spec.rb @@ -19,6 +19,25 @@ end end + describe "#previous_lanes" do + let(:promotion) { described_class.new(lane:) } + let(:lane) { :pre } + + subject { promotion.previous_lanes } + + it { is_expected.to eq([]) } + + context "if lane is default" do + let(:lane) { :default } + it { is_expected.to eq(["pre"]) } + end + + context "if lane is post" do + let(:lane) { :post } + it { is_expected.to eq(["pre", "default"]) } + end + end + describe "#destroy" do let!(:promotion) { create(:solidus_promotion, :with_adjustable_benefit, apply_automatically: true) } diff --git a/promotions/spec/models/spree/line_item_spec.rb b/promotions/spec/models/spree/line_item_spec.rb index 34a9d58029f..502ee7b6a35 100644 --- a/promotions/spec/models/spree/line_item_spec.rb +++ b/promotions/spec/models/spree/line_item_spec.rb @@ -57,4 +57,39 @@ end end end + + describe "adjusted_amount_by_lanes" do + let(:line_item) { described_class.new(price: 12, quantity: 4, adjustments: adjustments) } + let(:pre_adjustment) { Spree::Adjustment.new(amount: -1, source: pre_benefit) } + let(:default_adjustment) { Spree::Adjustment.new(amount: -2, source: default_benefit) } + let(:post_adjustment) { Spree::Adjustment.new(amount: -3, source: post_benefit) } + let(:pre_promotion) { SolidusPromotions::Promotion.new(lane: :pre) } + let(:default_promotion) { SolidusPromotions::Promotion.new(lane: :default) } + let(:post_promotion) { SolidusPromotions::Promotion.new(lane: :post) } + let(:pre_benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(promotion: pre_promotion) } + let(:default_benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(promotion: default_promotion) } + let(:post_benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(promotion: post_promotion) } + let(:adjustments) { [pre_adjustment, default_adjustment, post_adjustment] } + + let(:lanes) { [] } + + subject { line_item.adjusted_amount_by_lanes(lanes) } + it { is_expected.to eq(48) } + + context "if given pre lane" do + let(:lanes) { ["pre"] } + + it { is_expected.to eq(47) } + end + + context "if given default and pre lane" do + let(:lanes) { ["pre", "default"] } + it { is_expected.to eq(45) } + end + + context "if given default, pre and post lane" do + let(:lanes) { ["pre", "default", "post"] } + it { is_expected.to eq(42) } + end + end end diff --git a/promotions/spec/models/spree/price_spec.rb b/promotions/spec/models/spree/price_spec.rb new file mode 100644 index 00000000000..d75d0ee9628 --- /dev/null +++ b/promotions/spec/models/spree/price_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Spree::Price do + it { is_expected.to respond_to(:discountable_amount) } + it { is_expected.to respond_to(:current_discounts) } + it { is_expected.to respond_to(:discounted_amount) } + it { is_expected.to respond_to(:discounts) } + + context "with a discount" do + let(:price) { build(:price, current_discounts: [discount]) } + let(:discount) { SolidusPromotions::ItemDiscount.new(amount: -5, label: "Promo label") } + + it "does all the right things" do + expect(price.amount).to eq(19.99) + expect(price.discounted_amount).to eq(14.99) + expect(price.display_discounted_amount.to_s).to eq("$14.99") + expect(price.discounts).to include(discount) + end + end +end diff --git a/promotions/spec/models/spree/shipment_spec.rb b/promotions/spec/models/spree/shipment_spec.rb index 3e5ef0b49ac..73d12efdee2 100644 --- a/promotions/spec/models/spree/shipment_spec.rb +++ b/promotions/spec/models/spree/shipment_spec.rb @@ -36,4 +36,39 @@ end end end + + describe "adjusted_amount_by_lanes" do + let(:shipment) { described_class.new(cost: 48, adjustments: adjustments) } + let(:pre_adjustment) { Spree::Adjustment.new(amount: -1, source: pre_benefit) } + let(:default_adjustment) { Spree::Adjustment.new(amount: -2, source: default_benefit) } + let(:post_adjustment) { Spree::Adjustment.new(amount: -3, source: post_benefit) } + let(:pre_promotion) { SolidusPromotions::Promotion.new(lane: :pre) } + let(:default_promotion) { SolidusPromotions::Promotion.new(lane: :default) } + let(:post_promotion) { SolidusPromotions::Promotion.new(lane: :post) } + let(:pre_benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(promotion: pre_promotion) } + let(:default_benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(promotion: default_promotion) } + let(:post_benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(promotion: post_promotion) } + let(:adjustments) { [pre_adjustment, default_adjustment, post_adjustment] } + + let(:lanes) { [] } + + subject { shipment.adjusted_amount_by_lanes(lanes) } + it { is_expected.to eq(48) } + + context "if given pre lane" do + let(:lanes) { ["pre"] } + + it { is_expected.to eq(47) } + end + + context "if given default and pre lane" do + let(:lanes) { ["pre", "default"] } + it { is_expected.to eq(45) } + end + + context "if given default, pre and post lane" do + let(:lanes) { ["pre", "default", "post"] } + it { is_expected.to eq(42) } + end + end end diff --git a/promotions/spec/models/spree/variant_spec.rb b/promotions/spec/models/spree/variant_spec.rb new file mode 100644 index 00000000000..30f06745514 --- /dev/null +++ b/promotions/spec/models/spree/variant_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Spree::Variant do + let(:product) { create(:product) } + subject(:variant) { product.master } + describe "#undiscounted_price" do + subject { variant.undiscounted_price } + + it "raises an exception if called before discounting" do + expect { subject }.to raise_exception(SolidusPromotions::VariantPatch::VariantNotDiscounted) + end + + context "if variant is discounted" do + let(:order) { Spree::Order.create } + let(:pricing_options) { Spree::Config.pricing_options_class.new } + before do + SolidusPromotions::ProductDiscounter.new(product:, order:, pricing_options:).call + end + + it "is the same as the variant price" do + expect(subject).to eq(variant.price) + end + end + end +end