From d91214591ae8bab04594c5892592a66ef7b1f0e4 Mon Sep 17 00:00:00 2001 From: ThuktenSingye Date: Sun, 23 Nov 2025 22:34:04 +0600 Subject: [PATCH] Add configurable mergeable orders finder This patch introduces a customizable mechanism for determining which orders should be merged when a user logs in. Signed-off-by: Thukten Singye --- .../spree/core/controller_helpers/order.rb | 6 +- .../models/spree/mergeable_orders_finder.rb | 44 +++++++++++++++ core/lib/spree/app_configuration.rb | 7 +++ core/spec/lib/spree/app_configuration_spec.rb | 4 ++ .../spree/mergeable_orders_finder_spec.rb | 56 +++++++++++++++++++ core/spec/models/spree/order_spec.rb | 35 ++++++++++++ 6 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 core/app/models/spree/mergeable_orders_finder.rb create mode 100644 core/spec/models/spree/mergeable_orders_finder_spec.rb diff --git a/core/app/helpers/spree/core/controller_helpers/order.rb b/core/app/helpers/spree/core/controller_helpers/order.rb index dc8ffa17826..76abd6100c4 100644 --- a/core/app/helpers/spree/core/controller_helpers/order.rb +++ b/core/app/helpers/spree/core/controller_helpers/order.rb @@ -42,10 +42,8 @@ def associate_user end def set_current_order - if spree_current_user && current_order - spree_current_user.orders.by_store(current_store).incomplete.where(frontend_viewable: true).where('id != ?', current_order.id).find_each do |order| - current_order.merge!(order, spree_current_user) - end + Spree::Config.mergeable_orders_finder_class.new(context: self).call.find_each do |order| + current_order.merge!(order, spree_current_user) end end diff --git a/core/app/models/spree/mergeable_orders_finder.rb b/core/app/models/spree/mergeable_orders_finder.rb new file mode 100644 index 00000000000..b67d53c31e8 --- /dev/null +++ b/core/app/models/spree/mergeable_orders_finder.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Spree + # Finds orders to merge when a user logs in. + # + # Configurable via {Spree::Config#mergeable_orders_finder_class}. + # Default behavior finds all incomplete orders from the same store. + # + # @example Custom finder for recent orders only + # class RecentOrdersFinder + # def initialize(context:) + # @user = context.spree_current_user + # @store = context.current_store + # @current_order = context.current_order + # end + # + # def call + # @user.orders.by_store(@store).incomplete + # .where.not(id: @current_order.id) + # .where('created_at > ?', 7.days.ago) + # end + # end + # + # Spree::Config.mergeable_orders_finder_class = RecentOrdersFinder + class MergeableOrdersFinder + # @param context [Object] an object that responds to spree_current_user, + # current_store, and current_order (typically a controller) + def initialize(context:) + @user = context.spree_current_user + @store = context.current_store + @current_order = context.current_order + end + + # Returns orders that should be merged into the current order + # + # @return [ActiveRecord::Relation] incomplete orders from the + # same store + def call + return Spree::Order.none unless @user && @current_order + + @user.orders.by_store(@store).incomplete.where(frontend_viewable: true).where.not(id: @current_order.id) + end + end +end diff --git a/core/lib/spree/app_configuration.rb b/core/lib/spree/app_configuration.rb index 13b1492548b..aa95970682b 100644 --- a/core/lib/spree/app_configuration.rb +++ b/core/lib/spree/app_configuration.rb @@ -409,6 +409,13 @@ def default_pricing_options # as Spree::OrderMerger. class_name_attribute :order_merger_class, default: 'Spree::OrderMerger' + # Allows providing your own class for selecting which orders to merge. + # + # @!attribute [rw] mergeable_orders_finder_class + # @return [Class] a class with the same public interfaces as + # Spree::MergeableOrdersFinder. + class_name_attribute :mergeable_orders_finder_class, default: 'Spree::MergeableOrdersFinder' + # Allows providing your own class for adding default payments to a user's # order from their "wallet". # diff --git a/core/spec/lib/spree/app_configuration_spec.rb b/core/spec/lib/spree/app_configuration_spec.rb index 67f5220773c..c79c67e2ac8 100644 --- a/core/spec/lib/spree/app_configuration_spec.rb +++ b/core/spec/lib/spree/app_configuration_spec.rb @@ -48,6 +48,10 @@ expect(prefs.promotions).to be_a Spree::Core::NullPromotionConfiguration end + it 'uses mergeable orders finder class by default' do + expect(prefs.mergeable_orders_finder_class).to eq Spree::MergeableOrdersFinder + end + context "deprecated preferences" do around do |example| Spree.deprecator.silence do diff --git a/core/spec/models/spree/mergeable_orders_finder_spec.rb b/core/spec/models/spree/mergeable_orders_finder_spec.rb new file mode 100644 index 00000000000..89bbe989ddd --- /dev/null +++ b/core/spec/models/spree/mergeable_orders_finder_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module Spree + RSpec.describe MergeableOrdersFinder do + let(:user) { create(:user) } + let(:store) { create(:store) } + let(:current_order) { create(:order, user: user, store: store) } + let(:context) { double('context', spree_current_user: user, current_store: store, current_order: current_order) } + let(:subject) { Spree::MergeableOrdersFinder.new(context: context) } + + describe '#call' do + let!(:incomplete_order1) { create(:order, user: user, store: store, state: 'cart') } + let!(:incomplete_order2) { create(:order, user: user, store: store, state: 'address') } + let!(:complete_order) { create(:order, user: user, store: store, state: 'complete').touch(:completed_at) } + let!(:other_store_order) { create(:order, user: user, state: 'cart') } + + it 'returns incomplete orders from the same store' do + orders = subject.call + expect(orders).to include(incomplete_order1, incomplete_order2) + expect(orders).not_to include(current_order, complete_order, other_store_order) + end + + context 'when user is nil' do + let(:context) { double('context', spree_current_user: nil, current_store: store, current_order: current_order) } + + it 'returns empty relation' do + orders = subject.call + expect(orders).to be_empty + expect(orders).to eq(Spree::Order.none) + end + end + + context 'when current_order is nil' do + let(:context) { double('context', spree_current_user: user, current_store: store, current_order: nil) } + + it 'returns empty relation' do + orders = subject.call + expect(orders).to be_empty + expect(orders).to eq(Spree::Order.none) + end + end + + context 'when both user and current_order are nil' do + let(:context) { double('context', spree_current_user: nil, current_store: store, current_order: nil) } + + it 'returns empty relation' do + orders = subject.call + expect(orders).to be_empty + expect(orders).to eq(Spree::Order.none) + end + end + end + end +end diff --git a/core/spec/models/spree/order_spec.rb b/core/spec/models/spree/order_spec.rb index c4aae90bf1b..ebf51b24423 100644 --- a/core/spec/models/spree/order_spec.rb +++ b/core/spec/models/spree/order_spec.rb @@ -422,6 +422,41 @@ def merge!(other_order, user = nil) expect(order1.merge!(order2, user)).to eq([order1, order2, user]) end end + + describe 'mergeable_orders_finder_class customization' do + let(:user) { create(:user) } + let(:store) { create(:store) } + let(:current_order) { create(:order, user: user, store: store) } + let(:context) { double('context', spree_current_user: user, current_store: store, current_order: current_order) } + let(:test_mergeable_orders_finder_class) do + Class.new do + def initialize(context:) + @user = context.spree_current_user + @store = context.current_store + @current_order = context.current_order + end + + def call + @user.orders.by_store(@store).where.not(id: @current_order.id).where('created_at > ?', 7.days.ago) + end + end + end + + before do + stub_spree_preferences(mergeable_orders_finder_class: test_mergeable_orders_finder_class) + end + + subject(:finder) { Spree::Config.mergeable_orders_finder_class.new(context: context) } + + it 'uses the configured mergeable orders finder' do + old_order = create(:order, user: user, store: store, created_at: 8.days.ago) + recent_order = create(:order, user: user, store: store, created_at: 3.days.ago) + + orders = finder.call + expect(orders).to include(recent_order) + expect(orders).not_to include(old_order, current_order) + end + end end describe "#ensure_updated_shipments" do