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? %> -
+
<%= t("#{level}_actions", scope: :solidus_friendly_promotions) %> <%= render partial: 'solidus_friendly_promotions/admin/promotion_actions/promotion_action', collection: promotion_actions_by_level(@promotion, level), locals: {} %>
-
-
- <%= t("#{level}_rules", scope: :solidus_friendly_promotions) %> + <% if level != :order %> +
+
+ <%= t("#{level}_rules", scope: :solidus_friendly_promotions) %> - <%= render partial: 'solidus_friendly_promotions/admin/promotion_rules/promotion_rule', collection: promotion_rules_by_level(@promotion, level), locals: { level: level } %> + <%= render partial: 'solidus_friendly_promotions/admin/promotion_rules/promotion_rule', collection: promotion_rules_by_level(@promotion, level), locals: { level: level } %> - <%= turbo_frame_tag @promotion, "new_#{level}_promotion_rule" do %> - <%= link_to t(:add_rule, scope: :solidus_friendly_promotions), solidus_friendly_promotions.new_admin_promotion_promotion_rule_path(@promotion, level: level), class: 'btn btn-secondary' %> - <% end %> -
-
+ <%= turbo_frame_tag @promotion, "new_#{level}_promotion_rule" do %> + <%= link_to t(:add_rule, scope: :solidus_friendly_promotions), solidus_friendly_promotions.new_admin_promotion_promotion_rule_path(@promotion, level: level), class: 'btn btn-secondary' %> + <% end %> +
+
+ <% 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