diff --git a/app/decorators/models/solidus_friendly_promotions/line_item_decorator.rb b/app/decorators/models/solidus_friendly_promotions/line_item_decorator.rb
index 686d0686..832981d7 100644
--- a/app/decorators/models/solidus_friendly_promotions/line_item_decorator.rb
+++ b/app/decorators/models/solidus_friendly_promotions/line_item_decorator.rb
@@ -2,6 +2,20 @@
module SolidusFriendlyPromotions
module LineItemDecorator
+ def self.prepended(base)
+ base.belongs_to :managed_by_order_action, class_name: "SolidusFriendlyPromotions::PromotionAction", optional: true
+ base.validate :validate_managed_quantity_same, on: :update
+ end
+
+ private
+
+ def validate_managed_quantity_same
+ if managed_by_order_action && quantity_changed?
+ errors.add(:quantity, :cannot_be_changed_for_automated_items)
+ end
+ end
+
+ Spree::LineItem.prepend self
Spree::LineItem.prepend SolidusFriendlyPromotions::DiscountableAmount
end
end
diff --git a/app/decorators/models/solidus_friendly_promotions/order_decorator.rb b/app/decorators/models/solidus_friendly_promotions/order_decorator.rb
index 081ffdb5..53128bae 100644
--- a/app/decorators/models/solidus_friendly_promotions/order_decorator.rb
+++ b/app/decorators/models/solidus_friendly_promotions/order_decorator.rb
@@ -29,6 +29,13 @@ def reset_current_discounts
shipments.each(&:reset_current_discounts)
end
+ # This helper method excludes line items that are managed by an order action for the benefit
+ # of calculators and actions that discount normal line items. Line items that are managed by an
+ # order actions handle their discounts themselves.
+ def discountable_line_items
+ line_items.reject(&:managed_by_order_action)
+ end
+
def apply_shipping_promotions
if Spree::Config.promotion_adjuster_class <= SolidusFriendlyPromotions::FriendlyPromotionAdjuster
recalculate
@@ -37,6 +44,10 @@ def apply_shipping_promotions
end
end
+ def free_from_order_action?(line_item, _options)
+ !line_item.managed_by_order_action
+ end
+
Spree::Order.prepend self
end
end
diff --git a/app/javascript/solidus_friendly_promotions.js b/app/javascript/solidus_friendly_promotions.js
index 5a6d4506..856faa28 100644
--- a/app/javascript/solidus_friendly_promotions.js
+++ b/app/javascript/solidus_friendly_promotions.js
@@ -4,9 +4,12 @@ import "solidus_friendly_promotions/jquery/option_value_picker"
Turbo.session.drive = false;
-document.addEventListener("turbo:frame-load", ({ _target }) => {
+const initPickers = ({ _target }) => {
Spree.initNumberWithCurrency();
$(".product_picker").productAutocomplete();
$(".user_picker").userAutocomplete();
$(".taxon_picker").taxonAutocomplete();
-});
+ $(".variant_autocomplete").variantAutocomplete();
+};
+document.addEventListener("turbo:frame-load", initPickers);
+document.addEventListener("DOMContentLoaded", initPickers);
diff --git a/app/models/concerns/solidus_friendly_promotions/actions/order_level_action.rb b/app/models/concerns/solidus_friendly_promotions/actions/order_level_action.rb
new file mode 100644
index 00000000..7b2a879b
--- /dev/null
+++ b/app/models/concerns/solidus_friendly_promotions/actions/order_level_action.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module SolidusFriendlyPromotions
+ module Actions
+ module OrderLevelAction
+ def can_discount?(_)
+ false
+ end
+
+ def level
+ :order
+ end
+ end
+ end
+end
diff --git a/app/models/solidus_friendly_promotions/actions/adjust_line_item_quantity_groups.rb b/app/models/solidus_friendly_promotions/actions/adjust_line_item_quantity_groups.rb
index 7dc86e5e..777bfd5e 100644
--- a/app/models/solidus_friendly_promotions/actions/adjust_line_item_quantity_groups.rb
+++ b/app/models/solidus_friendly_promotions/actions/adjust_line_item_quantity_groups.rb
@@ -79,7 +79,7 @@ def compute_amount(line_item)
private
def actionable_line_items(order)
- order.line_items.select do |item|
+ order.discountable_line_items.select do |item|
promotion.rules.select do |rule|
rule.applicable?(item)
end.all? { |rule| rule.eligible?(item) }
diff --git a/app/models/solidus_friendly_promotions/actions/create_discounted_item.rb b/app/models/solidus_friendly_promotions/actions/create_discounted_item.rb
new file mode 100644
index 00000000..0f22842a
--- /dev/null
+++ b/app/models/solidus_friendly_promotions/actions/create_discounted_item.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module SolidusFriendlyPromotions
+ module Actions
+ class CreateDiscountedItem < PromotionAction
+ include OrderLevelAction
+ preference :variant_id, :integer
+ preference :quantity, :integer, default: 1
+
+ def perform(order)
+ line_item = find_item(order) || create_item(order)
+ line_item.current_discounts << discount(line_item)
+ end
+
+ def remove_from(order)
+ line_item = find_item(order)
+ order.line_items.destroy(line_item)
+ end
+
+ private
+
+ def find_item(order)
+ order.line_items.detect { |line_item| line_item.managed_by_order_action == self }
+ end
+
+ def create_item(order)
+ order.line_items.create!(quantity: preferred_quantity, variant: variant, managed_by_order_action: self)
+ end
+
+ def variant
+ Spree::Variant.find(preferred_variant_id)
+ end
+ end
+ end
+end
diff --git a/app/models/solidus_friendly_promotions/calculators/distributed_amount.rb b/app/models/solidus_friendly_promotions/calculators/distributed_amount.rb
index 5bb5f80a..15451ee2 100644
--- a/app/models/solidus_friendly_promotions/calculators/distributed_amount.rb
+++ b/app/models/solidus_friendly_promotions/calculators/distributed_amount.rb
@@ -28,7 +28,7 @@ def compute_line_item(line_item)
private
def eligible_line_items(order)
- order.line_items.select do |line_item|
+ order.discountable_line_items.select do |line_item|
calculable.promotion.eligible_by_applicable_rules?(line_item)
end
end
diff --git a/app/models/solidus_friendly_promotions/friendly_promotion_adjuster.rb b/app/models/solidus_friendly_promotions/friendly_promotion_adjuster.rb
index c5d1a3c3..48ce83db 100644
--- a/app/models/solidus_friendly_promotions/friendly_promotion_adjuster.rb
+++ b/app/models/solidus_friendly_promotions/friendly_promotion_adjuster.rb
@@ -19,6 +19,13 @@ def call
PersistDiscountedOrder.new(discounted_order).call unless dry_run
order.reset_current_discounts
+
+ unless dry_run
+ # Since automations might have added a line item, we need to recalculate item total and item count here.
+ order.item_total = order.line_items.sum(&:amount)
+ order.item_count = order.line_items.sum(&:quantity)
+ order.promo_total = (order.line_items + order.shipments).sum(&:promo_total)
+ end
order
end
end
diff --git a/app/models/solidus_friendly_promotions/friendly_promotion_adjuster/discount_order.rb b/app/models/solidus_friendly_promotions/friendly_promotion_adjuster/discount_order.rb
index ed58011a..d96daa18 100644
--- a/app/models/solidus_friendly_promotions/friendly_promotion_adjuster/discount_order.rb
+++ b/app/models/solidus_friendly_promotions/friendly_promotion_adjuster/discount_order.rb
@@ -16,6 +16,7 @@ def call
SolidusFriendlyPromotions::Promotion.ordered_lanes.each do |lane, _index|
lane_promotions = eligible_promotions_for_promotable(promotions.select { |promotion| promotion.lane == lane }, order)
+ perform_order_actions(lane_promotions, lane) unless dry_run
line_item_discounts = adjust_line_items(lane_promotions)
shipment_discounts = adjust_shipments(lane_promotions)
shipping_rate_discounts = adjust_shipping_rates(lane_promotions)
@@ -29,8 +30,25 @@ def call
private
+ def perform_order_actions(lane_promotions, lane)
+ lane_promotions.each do |promotion|
+ promotion.actions.select { |action| action.level == :order }.each { |action| action.perform(order) }
+ end
+
+ automated_line_items = order.line_items.select(&:managed_by_order_action)
+ return if automated_line_items.empty?
+
+ ineligible_line_items = automated_line_items.select do |line_item|
+ line_item.managed_by_order_action.promotion.lane == lane && !line_item.managed_by_order_action.in?(lane_promotions.flat_map(&:actions))
+ end
+
+ ineligible_line_items.each do |line_item|
+ line_item.managed_by_order_action.remove_from(order)
+ end
+ end
+
def adjust_line_items(promotions)
- order.line_items.select do |line_item|
+ order.discountable_line_items.select do |line_item|
line_item.variant.product.promotionable?
end.map do |line_item|
discounts = generate_discounts(promotions, line_item)
diff --git a/app/models/solidus_friendly_promotions/friendly_promotion_adjuster/persist_discounted_order.rb b/app/models/solidus_friendly_promotions/friendly_promotion_adjuster/persist_discounted_order.rb
index 5bc1e3e2..23a4bb6d 100644
--- a/app/models/solidus_friendly_promotions/friendly_promotion_adjuster/persist_discounted_order.rb
+++ b/app/models/solidus_friendly_promotions/friendly_promotion_adjuster/persist_discounted_order.rb
@@ -27,7 +27,6 @@ def call
end
end
order.reset_current_discounts
- order.promo_total = (order.line_items + order.shipments).sum(&:promo_total)
order
end
diff --git a/app/views/solidus_friendly_promotions/admin/promotion_actions/actions/_create_discounted_item.html.erb b/app/views/solidus_friendly_promotions/admin/promotion_actions/actions/_create_discounted_item.html.erb
new file mode 100644
index 00000000..87da02ec
--- /dev/null
+++ b/app/views/solidus_friendly_promotions/admin/promotion_actions/actions/_create_discounted_item.html.erb
@@ -0,0 +1,15 @@
+<%= fields_for param_prefix, promotion_action do |form| %>
+
+ <%= form.label :preferred_variant_id %>
+ <%= form.text_field :preferred_variant_id, class: "variant_autocomplete fullwidth" %>
+ <%= form.label :preferred_quantity %>
+ <%= form.number_field :preferred_quantity, class: "fullwidth" %>
+
+<% end %>
+
+<%= render(
+ "solidus_friendly_promotions/admin/promotion_actions/actions/calculator_fields",
+ promotion_action: promotion_action,
+ param_prefix: param_prefix,
+ form: form
+) %>
diff --git a/app/views/solidus_friendly_promotions/admin/promotions/edit.html.erb b/app/views/solidus_friendly_promotions/admin/promotions/edit.html.erb
index 57f3cc7a..d593acf7 100644
--- a/app/views/solidus_friendly_promotions/admin/promotions/edit.html.erb
+++ b/app/views/solidus_friendly_promotions/admin/promotions/edit.html.erb
@@ -48,26 +48,28 @@
- <% [:line_item, :shipment].each do |level| %>
+ <% [:order, :line_item, :shipment].each do |level| %>
<% if promotion_actions_by_level(@promotion, level).any? %>
-
+
-
-
+
+ <% end %>
<% end %>
<% end %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index b28b999c..b6312a99 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -23,6 +23,7 @@ en:
shipment_rules: Shipment Rules
line_item_actions: Line Item Actions
shipment_actions: Shipment Actions
+ order_actions: Order Actions
invalid_promotion_rule_level: Invalid Promotion Rule Level. Must be one of "order", "shipment", or "line_item"
invalid_promotion_action: Invalid promotion action.
invalid_promotion_rule: Invalid promotion rule.
@@ -161,6 +162,7 @@ en:
models:
solidus_friendly_promotions/actions/adjust_shipment: Discount matching shipments
solidus_friendly_promotions/actions/adjust_line_item: Discount matching line items
+ solidus_friendly_promotions/actions/create_discounted_item: Create discounted line item
solidus_friendly_promotions/actions/adjust_line_item_quantity_groups: Discount matching line items based on quantity groups
solidus_friendly_promotions/calculators/distributed_amount: Distributed Amount
solidus_friendly_promotions/calculators/percent: Flat Percent
@@ -199,6 +201,8 @@ en:
description: Creates a promotion credit on matching line items
solidus_friendly_promotions/actions/adjust_shipment:
description: Creates a promotion credit on matching shipments
+ solidus_friendly_promotions/actions/create_discounted_item:
+ description: Creates a discounted item
solidus_friendly_promotions/rules/first_order:
description: Must be the customer's first order
solidus_friendly_promotions/rules/first_repeat_purchase_since:
@@ -253,3 +257,7 @@ en:
attributes:
base:
disallowed_with_apply_automatically: Could not create promotion code on promotion that apply automatically
+ spree/line_item:
+ attributes:
+ quantity:
+ cannot_be_changed_for_automated_items: cannot be changed on a line item managed by a promotion action
diff --git a/db/migrate/20231104135812_add_managed_by_order_action_to_line_items.rb b/db/migrate/20231104135812_add_managed_by_order_action_to_line_items.rb
new file mode 100644
index 00000000..8d4f2c74
--- /dev/null
+++ b/db/migrate/20231104135812_add_managed_by_order_action_to_line_items.rb
@@ -0,0 +1,5 @@
+class AddManagedByOrderActionToLineItems < ActiveRecord::Migration[7.0]
+ def change
+ add_reference :spree_line_items, :managed_by_order_action, foreign_key: {to_table: :friendly_promotion_actions, null: true}
+ end
+end
diff --git a/lib/generators/solidus_friendly_promotions/install/templates/initializer.rb b/lib/generators/solidus_friendly_promotions/install/templates/initializer.rb
index 0632d153..d8424b3c 100644
--- a/lib/generators/solidus_friendly_promotions/install/templates/initializer.rb
+++ b/lib/generators/solidus_friendly_promotions/install/templates/initializer.rb
@@ -4,6 +4,10 @@
Spree::Config.order_contents_class = "SolidusFriendlyPromotions::SimpleOrderContents"
Spree::Config.promotion_adjuster_class = "SolidusFriendlyPromotions::FriendlyPromotionAdjuster"
+Rails.application.config.to_prepare do |config|
+ Spree::Order.line_item_comparison_hooks << :free_from_order_action?
+end
+
# Replace the promotions menu from core with ours
Spree::Backend::Config.configure do |config|
config.menu_items = config.menu_items.map do |item|
@@ -82,6 +86,10 @@
"SolidusFriendlyPromotions::Actions::AdjustLineItemQuantityGroups" => [
"SolidusFriendlyPromotions::Calculators::FlatRate",
"SolidusFriendlyPromotions::Calculators::Percent"
+ ],
+ "SolidusFriendlyPromotions::Actions::CreateDiscountedItem" => [
+ "SolidusFriendlyPromotions::Calculators::FlatRate",
+ "SolidusFriendlyPromotions::Calculators::Percent"
]
)
@@ -113,6 +121,7 @@
config.actions = [
"SolidusFriendlyPromotions::Actions::AdjustLineItem",
"SolidusFriendlyPromotions::Actions::AdjustLineItemQuantityGroups",
- "SolidusFriendlyPromotions::Actions::AdjustShipment"
+ "SolidusFriendlyPromotions::Actions::AdjustShipment",
+ "SolidusFriendlyPromotions::Actions::CreateDiscountedItem"
]
end
diff --git a/spec/models/promotion/integration_spec.rb b/spec/models/promotion/integration_spec.rb
index 0214b495..0d60a490 100644
--- a/spec/models/promotion/integration_spec.rb
+++ b/spec/models/promotion/integration_spec.rb
@@ -33,6 +33,53 @@
expect(order.line_items.flat_map(&:adjustments).length).to eq(2)
end
end
+
+ context "with an automation" do
+ let(:goodie) { create(:variant, price: 4) }
+ let(:action) { SolidusFriendlyPromotions::Actions::CreateDiscountedItem.new(preferred_variant_id: goodie.id, calculator: hundred_percent) }
+ let(:hundred_percent) { SolidusFriendlyPromotions::Calculators::Percent.new(preferred_percent: 100) }
+
+ it "creates a new discounted line item" do
+ expect(order.adjustments).to be_empty
+ expect(order.line_items.count).to eq(3)
+ expect(order.total).to eq(39.98)
+ expect(order.item_total).to eq(43.98)
+ expect(order.item_total_before_tax).to eq(39.98)
+ expect(order.line_items.flat_map(&:adjustments).length).to eq(1)
+ end
+
+ context "when the goodie becomes unavailable" do
+ before do
+ order.contents.remove(shirt.master)
+ end
+
+ it "removes the discounted line item" do
+ expect(order.adjustments).to be_empty
+ expect(order.line_items.length).to eq(1)
+ expect(order.promo_total).to eq(0)
+ expect(order.total).to eq(19.99)
+ expect(order.item_total).to eq(19.99)
+ expect(order.item_total_before_tax).to eq(19.99)
+ expect(order.line_items.flat_map(&:adjustments).length).to eq(0)
+ end
+ end
+
+ context "with a line-item level promotion in the lane before it" do
+ let!(:other_promotion) { create(:friendly_promotion, :with_adjustable_action, lane: :pre, apply_automatically: true) }
+
+ it "creates a new discounted line item" do
+ order.recalculate
+ expect(order.adjustments).to be_empty
+ expect(order.line_items.count).to eq(3)
+ expect(order.total).to eq(19.98)
+ expect(order.item_total).to eq(43.98)
+ expect(order.item_total_before_tax).to eq(19.98)
+ expect(order.line_items.flat_map(&:adjustments).length).to eq(3)
+ expect(order.line_items.detect { |line_item| line_item.managed_by_order_action == action }.adjustments.length).to eq(1)
+ expect(order.line_items.detect { |line_item| line_item.managed_by_order_action == action }.adjustments.first.amount).to eq(-4)
+ end
+ end
+ end
end
context "with a line-item level rule" do
diff --git a/spec/models/solidus_friendly_promotions/actions/create_discounted_item_spec.rb b/spec/models/solidus_friendly_promotions/actions/create_discounted_item_spec.rb
new file mode 100644
index 00000000..6705ec53
--- /dev/null
+++ b/spec/models/solidus_friendly_promotions/actions/create_discounted_item_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe SolidusFriendlyPromotions::Actions::CreateDiscountedItem do
+ it { is_expected.to respond_to(:preferred_variant_id) }
+
+ describe "#perform" do
+ let(:order) { create(:order_with_line_items) }
+ let(:promotion) { create(:friendly_promotion) }
+ let(:action) { SolidusFriendlyPromotions::Actions::CreateDiscountedItem.new(preferred_variant_id: goodie.id, calculator: hundred_percent, promotion: promotion) }
+ let(:hundred_percent) { SolidusFriendlyPromotions::Calculators::Percent.new(preferred_percent: 100) }
+ let(:goodie) { create(:variant) }
+ subject { action.perform(order) }
+
+ it "creates a line item with a hundred percent discount" do
+ expect { subject }.to change { order.line_items.count }.by(1)
+ created_item = order.line_items.detect { |line_item| line_item.managed_by_order_action == action }
+ expect(created_item.discountable_amount).to be_zero
+ end
+
+ it "never calls the order recalculator" do
+ expect(order).not_to receive(:recalculate)
+ end
+ end
+end
diff --git a/spec/models/solidus_friendly_promotions/simple_order_contents_spec.rb b/spec/models/solidus_friendly_promotions/simple_order_contents_spec.rb
index 11c44a17..8d825d71 100644
--- a/spec/models/solidus_friendly_promotions/simple_order_contents_spec.rb
+++ b/spec/models/solidus_friendly_promotions/simple_order_contents_spec.rb
@@ -17,6 +17,20 @@
expect(line_item.quantity).to eq(1)
expect(order.line_items.size).to eq(1)
end
+
+ context "if a line item managed by an automation exists" do
+ let(:promotion) { create(:friendly_promotion, apply_automatically: true) }
+ let(:promotion_action) { SolidusFriendlyPromotions::Actions::CreateDiscountedItem.create!(calculator: hundred_percent, preferred_variant_id: variant.id, promotion: promotion) }
+ let(:hundred_percent) { SolidusFriendlyPromotions::Calculators::Percent.new(preferred_percent: 100) }
+
+ before do
+ order.line_items.create!(variant: variant, managed_by_order_action: promotion_action, quantity: 1)
+ end
+
+ specify "creating a new line item with the same variant creates a separate item" do
+ expect { subject.add(variant) }.to change { order.line_items.length }.by(1)
+ end
+ end
end
context "given a shipment" do
diff --git a/spec/models/spree/line_item_spec.rb b/spec/models/spree/line_item_spec.rb
index 44fc8733..8b7d2a06 100644
--- a/spec/models/spree/line_item_spec.rb
+++ b/spec/models/spree/line_item_spec.rb
@@ -3,6 +3,8 @@
require "spec_helper"
RSpec.describe Spree::LineItem do
+ it { is_expected.to belong_to(:managed_by_order_action).optional }
+
describe "#discountable_amount" do
let(:discounts) { [] }
let(:line_item) { Spree::LineItem.new(price: 10, quantity: 2, current_discounts: discounts) }
@@ -34,4 +36,25 @@
expect { subject }.to change { line_item.current_discounts.length }.from(1).to(0)
end
end
+
+ describe "changing quantities" do
+ context "when line item is managed by an automation" do
+ let(:order) { create(:order) }
+ let(:variant) { create(:variant) }
+ let(:promotion) { create(:friendly_promotion, apply_automatically: true) }
+ let(:promotion_action) { SolidusFriendlyPromotions::Actions::CreateDiscountedItem.create!(calculator: hundred_percent, preferred_variant_id: variant.id, promotion: promotion) }
+ let(:hundred_percent) { SolidusFriendlyPromotions::Calculators::Percent.new(preferred_percent: 100) }
+
+ before do
+ order.line_items.create!(variant: variant, managed_by_order_action: promotion_action, quantity: 1)
+ end
+
+ it "makes the line item invalid" do
+ line_item = order.line_items.first
+ line_item.quantity = 2
+ expect { line_item.save! }.to raise_exception(ActiveRecord::RecordInvalid)
+ expect(line_item.errors.full_messages.first).to eq("Quantity cannot be changed on a line item managed by a promotion action")
+ end
+ end
+ end
end