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
@@ -0,0 +1,20 @@
# frozen_string_literal: true

module SolidusPromotions
module AdjustmentDiscounts
private

# Returns adjustments from specified promotion lanes.
#
# @param lanes [Array<String>] the promotion lanes to filter by
# @return [Array<Spree::Adjustment>] promotions adjustments from the
# specified lanes that are not marked for destruction
def discounts_by_lanes(lanes)
adjustments.select do |adjustment|
!adjustment.marked_for_destruction? &&
adjustment.source_type == "SolidusPromotions::Benefit" &&
adjustment.source.promotion.lane.in?(lanes)
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

module SolidusPromotions
module DiscountedAmount
class NotCalculatingPromotions < StandardError
DEFAULT_MESSAGE = <<~MSG
You're trying to call `#current_lane_discounts` without a current lane being set on `SolidusPromotions::PromotionLane.
In order to set a current lane, wrap your call into a `PromotionLane.set` block:
```
SolidusPromotions::PromotionLane.set(current_lane: "default") do
# YOUR CODE HERE
end
```
MSG

def initialize
super(DEFAULT_MESSAGE)
end
end

# Calculates the total discounted amount including adjustments from previous lanes.
#
# @return [BigDecimal] the sum of the current amount and all previous lane discount amounts
def discounted_amount
amount + previous_lanes_discounts.sum(&:amount)
end

# Returns discount objects from the current promotion lane.
#
# @return [Array<Spree::Adjustment,SolidusPromotions::ShippingRateDiscount>] Discounts from the current lane
# @raise [NotCalculatingPromotions] if no promotion lane is currently being calculated
def current_lane_discounts
raise NotCalculatingPromotions unless PromotionLane.current_lane

discounts_by_lanes([PromotionLane.current_lane])
end

private

# Returns discount objects added by promotion in lanes that come before the current lane.
#
# This method retrieves all discounts that were applied by promotion lanes with a priority
# lower than the current lane, effectively getting discounts from earlier processing stages.
#
# @return [Array<Spree::Adjustment,SolidusPromotions::ShippingRateDiscount>] Discounts from previous lanes
# @see #discounts_by_lanes
# @see PromotionLane.previous_lanes
def previous_lanes_discounts
discounts_by_lanes(PromotionLane.previous_lanes)
end
end
end
48 changes: 48 additions & 0 deletions promotions/app/models/solidus_promotions/promotion_lane.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# frozen_string_literal: true

module SolidusPromotions
# PromotionLane is a thread-safe current attributes class that manages the current promotion lane context.
#
# This class extends ActiveSupport::CurrentAttributes to provide thread-local storage for the current
# promotion lane. It allows setting and retrieving the current lane, as well as getting all lanes
# that come before the current one.
#
# @example Setting and retrieving the current lane
# PromotionLane.current_lane = :pre
# PromotionLane.current_lane # => "pre"
#
# @example Getting lanes before the current one
# PromotionLane.current_lane = :post
# PromotionLane.previous_lanes # => ["pre"]
#
# @see ActiveSupport::CurrentAttributes
class PromotionLane < ActiveSupport::CurrentAttributes
attribute :current_lane

def current_lane=(arg)
if arg.present?
super(arg.to_s)
else
super
end
end

# Retrieves the lanes that occur before the current lane in the promotion flow.
#
# Delegates to `before(current_lane)` to compute the preceding lanes.
#
# Special considerations:
# - If `current_lane` is `nil`, all lanes are returned.
#
# @return [Array<String>] the set of lanes preceding the current lane; all lanes if no current lane is set
def previous_lanes
before(current_lane)
end

private

def before(lane)
Promotion.ordered_lanes.split(lane).first
Copy link
Contributor

@manleyac manleyac Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is critical, and it took me some time to realize why. Having :previous_lanes return all lanes when :current_lane is nil allows calling :discounted_amount outside of the context of the promotion system, like in a serializer after the order adjuster ran, and it simply summing all adjustments to return the discounted amount. Once the lifecycle of adjustments can be fully managed in memory, then we can have true preview or "strike-through" pricing

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even better: When calculating discounts for prices (as we want to do in the future), we can even get the discount for a line item within the lane the price promotion is in. It'll be pretty neat.

end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ def reset_quantity_setter
end

Spree::LineItem.prepend self
Spree::LineItem.prepend SolidusPromotions::AdjustmentDiscounts
Spree::LineItem.prepend SolidusPromotions::DiscountedAmount
Spree::LineItem.prepend SolidusPromotions::DiscountableAmount
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ def reset_current_discounts
end

Spree::Shipment.prepend self
Spree::Shipment.prepend SolidusPromotions::AdjustmentDiscounts
Spree::Shipment.prepend SolidusPromotions::DiscountedAmount
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,22 @@ def promo_total
discounts.sum(&:amount)
end

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.marked_for_destruction? &&
discount.benefit.promotion.lane.in?(lanes)
end
end

Spree::ShippingRate.prepend SolidusPromotions::DiscountableAmount
Spree::ShippingRate.prepend SolidusPromotions::DiscountedAmount
Spree::ShippingRate.prepend self
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe SolidusPromotions::PromotionLane do
describe ".before_current" do
let(:lane) { :pre }

subject { described_class.previous_lanes }

it { is_expected.to eq(["pre", "default", "post"]) }

context "if lane is given" do
let(:lane) { :pre }

around do |example|
described_class.set(current_lane: lane) do
example.run
end
end

it { is_expected.to be_empty }

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
end

describe ".set(current:)" do
let(:lane) { :pre }

it "runs blocks with current_lane set to lane" do
expect(described_class.current_lane).to be nil
described_class.set(current_lane: lane) do
expect(described_class.current_lane).to eq("pre")
end
expect(described_class.current_lane).to be nil
end

it "can be nested" do
expect(described_class.current_lane).to be nil
described_class.set(current_lane: lane) do
expect(described_class.current_lane).to eq("pre")
described_class.set(current_lane: "default") do
expect(described_class.current_lane).to eq("default")
end
expect(described_class.current_lane).to eq("pre")
end
expect(described_class.current_lane).to be nil
end
end
end
78 changes: 78 additions & 0 deletions promotions/spec/models/spree/line_item_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,82 @@
end
end
end

describe "#discounted_amount" do
let(:order) { Spree::Order.new }
let(:tax_rate) { create(:tax_rate) }
let(:pre_lane_promotion) { create(:solidus_promotion, :with_adjustable_benefit, lane: :pre) }
let(:post_lane_promotion) { create(:solidus_promotion, :with_adjustable_benefit, lane: :post) }
let(:line_item) { Spree::LineItem.new(adjustments:, order:, price: 14, quantity: 2) }
let(:adjustments) { [tax_adjustment, pre_lane_adjustment, post_lane_adjustment] }
let(:tax_adjustment) { Spree::Adjustment.new(source: tax_rate, amount: 2) }
let(:pre_lane_adjustment) { Spree::Adjustment.new(source: pre_lane_promotion.benefits.first, amount: -3) }
let(:post_lane_adjustment) { Spree::Adjustment.new(source: post_lane_promotion.benefits.first, amount: -2) }

subject { line_item.discounted_amount }

it "counts adjustments from all lanes by default" do
is_expected.to eq(23)
end

context "if current lane is default lane" do
around do |example|
SolidusPromotions::PromotionLane.set(current_lane: :default) do
example.run
end
end

it { is_expected.to eq(25) }
end

context "if an adjustment is marked for deletion" do
before do
pre_lane_adjustment.mark_for_destruction
end

it { is_expected.to eq(26) }
end
end

describe "#current_lane_discounts" do
let(:order) { Spree::Order.new }
let(:tax_rate) { create(:tax_rate) }
let(:pre_lane_promotion) { create(:solidus_promotion, :with_adjustable_benefit, lane: :pre) }
let(:post_lane_promotion) { create(:solidus_promotion, :with_adjustable_benefit, lane: :post) }
let(:line_item) { Spree::LineItem.new(adjustments:, order:, price: 14, quantity: 2) }
let(:adjustments) { [tax_adjustment, pre_lane_adjustment, post_lane_adjustment] }
let(:tax_adjustment) { Spree::Adjustment.new(source: tax_rate, amount: 2) }
let(:pre_lane_adjustment) { Spree::Adjustment.new(source: pre_lane_promotion.benefits.first, amount: -3) }
let(:post_lane_adjustment) { Spree::Adjustment.new(source: post_lane_promotion.benefits.first, amount: -2) }

subject { line_item.current_lane_discounts }

it "raises an exception when there is no current lane" do
expect { subject }.to raise_exception(SolidusPromotions::DiscountedAmount::NotCalculatingPromotions)
end

context "when in pre lane" do
before do
allow(SolidusPromotions::PromotionLane).to receive(:current_lane) { "pre" }
end

it { is_expected.to contain_exactly(pre_lane_adjustment) }
end

context "when in default lane" do
before do
allow(SolidusPromotions::PromotionLane).to receive(:current_lane) { "default" }
end

it { is_expected.to be_empty }
end

context "when in post lane" do
before do
allow(SolidusPromotions::PromotionLane).to receive(:current_lane) { "post" }
end

it { is_expected.to contain_exactly(post_lane_adjustment) }
end
end
end
78 changes: 78 additions & 0 deletions promotions/spec/models/spree/shipment_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,82 @@
end
end
end

describe "#discounted_amount" do
let(:order) { Spree::Order.new }
let(:tax_rate) { create(:tax_rate) }
let(:pre_lane_promotion) { create(:solidus_promotion, :with_adjustable_benefit, lane: :pre) }
let(:post_lane_promotion) { create(:solidus_promotion, :with_adjustable_benefit, lane: :post) }
let(:shipment) { Spree::Shipment.new(adjustments:, order:, cost: 14) }
let(:adjustments) { [tax_adjustment, pre_lane_adjustment, post_lane_adjustment] }
let(:tax_adjustment) { Spree::Adjustment.new(source: tax_rate, amount: 2) }
let(:pre_lane_adjustment) { Spree::Adjustment.new(source: pre_lane_promotion.benefits.first, amount: -3) }
let(:post_lane_adjustment) { Spree::Adjustment.new(source: post_lane_promotion.benefits.first, amount: -2) }

subject { shipment.discounted_amount }

it "counts adjustments from all lanes by default" do
is_expected.to eq(9)
end

context "if current lane is default lane" do
around do |example|
SolidusPromotions::PromotionLane.set(current_lane: :default) do
example.run
end
end

it { is_expected.to eq(11) }
end

context "if an adjustment is marked for deletion" do
before do
pre_lane_adjustment.mark_for_destruction
end

it { is_expected.to eq(12) }
end
end

describe "#current_lane_discounts" do
let(:order) { Spree::Order.new }
let(:tax_rate) { create(:tax_rate) }
let(:pre_lane_promotion) { create(:solidus_promotion, :with_adjustable_benefit, lane: :pre) }
let(:post_lane_promotion) { create(:solidus_promotion, :with_adjustable_benefit, lane: :post) }
let(:shipment) { Spree::Shipment.new(adjustments:, order:, cost: 14) }
let(:adjustments) { [tax_adjustment, pre_lane_adjustment, post_lane_adjustment] }
let(:tax_adjustment) { Spree::Adjustment.new(source: tax_rate, amount: 2) }
let(:pre_lane_adjustment) { Spree::Adjustment.new(source: pre_lane_promotion.benefits.first, amount: -3) }
let(:post_lane_adjustment) { Spree::Adjustment.new(source: post_lane_promotion.benefits.first, amount: -2) }

subject { shipment.current_lane_discounts }

it "raises an exception when there is no current lane" do
expect { subject }.to raise_exception(SolidusPromotions::DiscountedAmount::NotCalculatingPromotions)
end

context "when in pre lane" do
before do
allow(SolidusPromotions::PromotionLane).to receive(:current_lane) { "pre" }
end

it { is_expected.to contain_exactly(pre_lane_adjustment) }
end

context "when in default lane" do
before do
allow(SolidusPromotions::PromotionLane).to receive(:current_lane) { "default" }
end

it { is_expected.to be_empty }
end

context "when in post lane" do
before do
allow(SolidusPromotions::PromotionLane).to receive(:current_lane) { "post" }
end

it { is_expected.to contain_exactly(post_lane_adjustment) }
end
end
end
Loading
Loading