diff options
author | Alessandro Desantis <desa.alessandro@gmail.com> | 2020-11-19 13:14:38 +0100 |
---|---|---|
committer | Alessandro Desantis <desa.alessandro@gmail.com> | 2021-01-30 15:23:41 +0100 |
commit | 16dd27dab051bfd7fd8edd7721391e3bd80a62ea (patch) | |
tree | b5cb7d1e4f90c8c227fcb64abb49d5f3a0b9f895 | |
parent | 0a0659d1150a7df8f0ca23e4876672e95a103f51 (diff) |
Streamline and simplify `SolidusSubscriptions::Checkout`
This service object contained a lot of indirection and took on too
many responsibilities. The new version is much more streamlined and
the flow of operations should be much clearer.
6 files changed, 127 insertions, 588 deletions
diff --git a/app/jobs/solidus_subscriptions/process_installment_job.rb b/app/jobs/solidus_subscriptions/process_installment_job.rb index 6beeec6..17eb446 100644 --- a/app/jobs/solidus_subscriptions/process_installment_job.rb +++ b/app/jobs/solidus_subscriptions/process_installment_job.rb @@ -5,7 +5,7 @@ module SolidusSubscriptions queue_as SolidusSubscriptions.configuration.processing_queue def perform(installment) - Checkout.new([installment]).process + Checkout.new(installment).process end end end diff --git a/app/services/solidus_subscriptions/checkout.rb b/app/services/solidus_subscriptions/checkout.rb index c40849a..e348547 100644 --- a/app/services/solidus_subscriptions/checkout.rb +++ b/app/services/solidus_subscriptions/checkout.rb @@ -1,155 +1,73 @@ # frozen_string_literal: true -# This class takes a collection of installments and populates a new spree -# order with the correct contents based on the subscriptions associated to the -# intallments. This is to group together subscriptions being -# processed on the same day for a specific user module SolidusSubscriptions class Checkout - # @return [Array<Installment>] The collection of installments to be used - # when generating a new order - attr_reader :installments + attr_reader :installment - delegate :user, to: :subscription - - # Get a new instance of a Checkout - # - # @param installments [Array<Installment>] The collection of installments - # to be used when generating a new order - def initialize(installments) - @installments = installments - raise UserMismatchError.new(installments) if different_owners? + def initialize(installment) + @installment = installment end - # Generate a new Spree::Order based on the information associated to the - # installments - # - # @return [Spree::Order] def process - populate - - # Installments are removed and set for future processing if they are - # out of stock. If there are no line items left there is nothing to do - return if installments.empty? - - if checkout - SolidusSubscriptions.configuration.success_dispatcher_class.new(installments, order).dispatch - return order + order = create_order + + begin + populate_order(order) + finalize_order(order) + + SolidusSubscriptions.configuration.success_dispatcher_class.new([installment], order).dispatch + rescue StateMachines::InvalidTransition + if order.payments.any?(&:failed?) + SolidusSubscriptions.configuration.payment_failed_dispatcher_class.new([installment], order).dispatch + else + SolidusSubscriptions.configuration.failure_dispatcher_class.new([installment], order).dispatch + end + rescue ::Spree::Order::InsufficientStock + SolidusSubscriptions.configuration.out_of_stock_dispatcher_class.new([installment], order).dispatch end - # A new order will only have 1 payment that we created - if order.payments.any?(&:failed?) - SolidusSubscriptions.configuration.payment_failed_dispatcher_class.new(installments, order).dispatch - installments.clear - nil - end - ensure - # Any installments that failed to be processed will be reprocessed - unfulfilled_installments = installments.select(&:unfulfilled?) - if unfulfilled_installments.any? - SolidusSubscriptions.configuration.failure_dispatcher_class. - new(unfulfilled_installments, order).dispatch - end + order end - # The order fulfilling the consolidated installment - # - # @return [Spree::Order] - def order - @order ||= ::Spree::Order.create( - user: user, - email: user.email, - store: subscription.store || ::Spree::Store.default, + private + + def create_order + ::Spree::Order.create( + user: installment.subscription.user, + email: installment.subscription.user.email, + store: installment.subscription.store || ::Spree::Store.default, subscription_order: true, - subscription: subscription + subscription: installment.subscription ) end - private + def populate_order(order) + installment.subscription.line_items.each do |line_item| + order.contents.add(line_item.subscribable, line_item.quantity) + end + end - def checkout + def finalize_order(order) + ::Spree::PromotionHandler::Cart.new(order).activate order.recalculate - apply_promotions order.checkout_steps[0...-1].each do case order.state - when "address" - order.ship_address = ship_address - order.bill_address = bill_address - when "payment" - create_payment + when 'address' + order.ship_address = installment.subscription.shipping_address_to_use + order.bill_address = installment.subscription.billing_address_to_use + when 'payment' + order.payments.create( + payment_method: installment.subscription.payment_method_to_use, + source: installment.subscription.payment_source_to_use, + amount: order.total, + ) end order.next! end - # Do this as a separate "quiet" transition so that it returns true or - # false rather than raising a failed transition error - order.complete - end - - def populate - unfulfilled_installments = [] - - order_line_items = installments.flat_map do |installment| - line_items = installment.line_item_builder.spree_line_items - - unfulfilled_installments.push(installment) if line_items.empty? - - line_items - end - - # Remove installments which had no stock from the active list - # They will be reprocessed later - @installments -= unfulfilled_installments - if unfulfilled_installments.any? - SolidusSubscriptions.configuration.out_of_stock_dispatcher_class.new(unfulfilled_installments).dispatch - end - - return if installments.empty? - - order_builder.add_line_items(order_line_items) - end - - def order_builder - @order_builder ||= OrderBuilder.new(order) - end - - def subscription - installments.first.subscription - end - - def ship_address - subscription.shipping_address_to_use - end - - def bill_address - subscription.billing_address_to_use - end - - def payment_source - subscription.payment_source_to_use - end - - def payment_method - subscription.payment_method_to_use - end - - def create_payment - order.payments.create( - source: payment_source, - amount: order.total, - payment_method: payment_method, - ) - end - - def apply_promotions - ::Spree::PromotionHandler::Cart.new(order).activate - order.updater.update # reload totals - end - - def different_owners? - installments.map { |i| i.subscription.user }.uniq.length > 1 + order.complete! end end end diff --git a/app/services/solidus_subscriptions/order_builder.rb b/app/services/solidus_subscriptions/order_builder.rb deleted file mode 100644 index a577e98..0000000 --- a/app/services/solidus_subscriptions/order_builder.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -# This class is responsible for adding line items to order without going -# through order contents. -module SolidusSubscriptions - class OrderBuilder - attr_reader :order - - # Get a new instance of an OrderBuilder - # - # @param order [Spree::Order] The order to be built - # - # @return [SolidusSubscriptions::OrderBuilder] - def initialize(order) - @order = order - end - - # Add line items to an order. If the order already - # has a line item for a given variant_id, update the quantity. Otherwise - # add the line item to the order. - # - # @param items [Array<Spree::LineItem>] The order to add the line item to - # @return [Array<Spree::LineItem] The collection that was passed in - def add_line_items(items) - items.map { |item| add_item_to_order(item) } - end - - private - - def add_item_to_order(new_item) - line_item = order.line_items.detect do |li| - li.variant_id == new_item.variant_id - end - - if line_item - line_item.increment!(:quantity, new_item.quantity) - else - order.line_items << new_item - end - end - end -end diff --git a/app/services/solidus_subscriptions/user_mismatch_error.rb b/app/services/solidus_subscriptions/user_mismatch_error.rb deleted file mode 100644 index 1d227ca..0000000 --- a/app/services/solidus_subscriptions/user_mismatch_error.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module SolidusSubscriptions - class UserMismatchError < StandardError - def initialize(installments) - @installments = installments - end - - def to_s - <<-MSG.squish - Installments must have the same user to be processed as a consolidated - installment. Could not process installments: - #{@installments.map(&:id).join(', ')} - MSG - end - end -end diff --git a/spec/services/solidus_subscriptions/checkout_spec.rb b/spec/services/solidus_subscriptions/checkout_spec.rb index 4b408dc..f35b8c0 100644 --- a/spec/services/solidus_subscriptions/checkout_spec.rb +++ b/spec/services/solidus_subscriptions/checkout_spec.rb @@ -1,389 +1,122 @@ -require 'spec_helper' +RSpec.describe SolidusSubscriptions::Checkout, :checkout do + context 'when the order can be created and paid' do + it 'creates and finalizes a new order for the installment' do + stub_spree_preferences(auto_capture: true) + installment = create(:installment, :actionable) -RSpec.describe SolidusSubscriptions::Checkout do - let(:checkout) { described_class.new(installments) } - let(:root_order) { create :completed_order_with_pending_payment, user: subscription_user } - let(:subscription_user) { create(:user, :subscription_user) } - let!(:credit_card) { - card = create(:credit_card, user: subscription_user, gateway_customer_profile_id: 'BGS-123', payment_method: payment_method) - wallet_payment_source = subscription_user.wallet.add(card) - subscription_user.wallet.default_wallet_payment_source = wallet_payment_source - card - } - let(:payment_method) { create(:payment_method) } - let(:installments) { create_list(:installment, 2, installment_traits) } + order = described_class.new(installment).process - let(:installment_traits) do - { - subscription_traits: [{ - user: subscription_user, - line_item_traits: [{ - spree_line_item: root_order.line_items.first - }] - }] - } - end - - before do - Spree::Variant.all.each { |v| v.update(subscribable: true) } - end - - context 'initialized with installments belonging to multiple users' do - subject { checkout } - - let(:installments) { build_stubbed_list :installment, 2 } - - it 'raises an error' do - expect { subject }. - to raise_error SolidusSubscriptions::UserMismatchError, /must have the same user/ + expect(order).to be_complete + expect(order).to be_paid end - end - - describe '#process', :checkout do - subject(:order) { checkout.process } - - let(:subscription_line_item) { installments.first.subscription.line_items.first } - - shared_examples 'a completed checkout' do - it { is_expected.to be_a Spree::Order } - - let(:total) { 49.98 } - let(:quantity) { installments.length } - - it 'has the correct number of line items' do - count = order.line_items.length - expect(count).to eq quantity - end - it 'the line items have the correct values' do - line_item = order.line_items.first - expect(line_item).to have_attributes( - quantity: subscription_line_item.quantity, - variant_id: subscription_line_item.subscribable_id - ) - end - - it 'has a shipment' do - expect(order.shipments).to be_present - end - - it 'has a payment' do - expect(order.payments.valid).to be_present - end - - it 'has the correct totals' do - expect(order).to have_attributes( - total: total, - shipment_total: 10 - ) - end + # rubocop:disable RSpec/MultipleExpectations + it 'copies basic information from the subscription' do + stub_spree_preferences(auto_capture: true) + installment = create(:installment, :actionable) + subscription = installment.subscription - it { is_expected.to be_complete } + order = described_class.new(installment).process - it 'associates the order to the installment detail' do - order - installment_orders = installments.flat_map { |i| i.details.map(&:order) }.compact - expect(installment_orders).to all eq order - end - - it 'creates an installment detail for each installment' do - expect { subject }. - to change { SolidusSubscriptions::InstallmentDetail.count }. - by(installments.count) - end + expect(order.ship_address.value_attributes).to eq(subscription.shipping_address_to_use.value_attributes) + expect(order.bill_address.value_attributes).to eq(subscription.billing_address_to_use.value_attributes) + expect(order.payments.first.payment_method).to eq(subscription.payment_method_to_use) + expect(order.payments.first.source).to eq(subscription.payment_source_to_use) + expect(order.user).to eq(subscription.user) + expect(order.email).to eq(subscription.user.email) end + # rubocop:enable RSpec/MultipleExpectations - context 'no line items get added to the cart' do - before do - installments - Spree::StockItem.update_all(count_on_hand: 0, backorderable: false) - end - - it 'creates two failed installment details' do - expect { order }. - to change { SolidusSubscriptions::InstallmentDetail.count }. - by(installments.length) + it 'marks the order as a subscription order' do + stub_spree_preferences(auto_capture: true) + installment = create(:installment, :actionable) + subscription = installment.subscription - details = SolidusSubscriptions::InstallmentDetail.last(installments.length) - expect(details).to all be_failed - end + order = described_class.new(installment).process - it { is_expected.to be_nil } - - it 'creates no order' do - expect { subject }.not_to change { Spree::Order.count } - end + expect(order.subscription).to eq(subscription) + expect(order.subscription_order).to eq(true) end - if Gem::Specification.find_by_name('solidus').version >= Gem::Version.new('1.4.0') - context 'Altered checkout flow' do - before do - @old_checkout_flow = Spree::Order.checkout_flow - Spree::Order.remove_checkout_step(:delivery) - end + it 'matches the total on the subscription' do + stub_spree_preferences(auto_capture: true) + installment = create(:installment, :actionable) + subscription = installment.subscription - after do - Spree::Order.checkout_flow(&@old_checkout_flow) - end + order = described_class.new(installment).process - it 'has a payment' do - expect(order.payments.valid).to be_present - end - - it 'has the correct totals' do - expect(order).to have_attributes( - total: 39.98, - shipment_total: 0 - ) - end - - it { is_expected.to be_complete } - end + expect(order.item_total).to eq(subscription.line_items.first.subscribable.price) end - context 'the variant is out of stock' do - let(:subscription_line_item) { installments.last.subscription.line_items.first } - let(:expected_date) { Time.zone.today + SolidusSubscriptions.configuration.reprocessing_interval } - - # Remove stock for 1 variant in the consolidated installment - before do - subscribable_id = installments.first.subscription.line_items.first.subscribable_id - variant = Spree::Variant.find(subscribable_id) - variant.stock_items.update_all(count_on_hand: 0, backorderable: false) - end - - it 'creates a failed installment detail' do - subject - detail = installments.first.details.last - - expect(detail).not_to be_successful - expect(detail.message). - to eq I18n.t('solidus_subscriptions.installment_details.out_of_stock') - end - - it 'removes the installment from the list of installments' do - expect { subject }. - to change { checkout.installments.length }. - by(-1) - end - - it_behaves_like 'a completed checkout' do - let(:total) { 29.99 } - let(:quantity) { installments.length - 1 } - end - end - - context 'the payment fails' do - let(:payment_method) { create(:payment_method) } - let!(:credit_card) { - card = create(:credit_card, user: checkout.user, payment_method: payment_method) - wallet_payment_source = checkout.user.wallet.add(card) - checkout.user.wallet.default_wallet_payment_source = wallet_payment_source - card - } - let(:expected_date) { Time.zone.today + SolidusSubscriptions.configuration.reprocessing_interval } - - it { is_expected.to be_nil } - - it 'marks all of the installments as failed' do - subject - - details = installments.map do |installments| - installments.details.reload.last - end - - expect(details).to all be_failed && have_attributes( - message: I18n.t('solidus_subscriptions.installment_details.payment_failed') - ) - end + it 'calls the success dispatcher' do + stub_spree_preferences(auto_capture: true) + installment = create(:installment, :actionable) + success_dispatcher = stub_dispatcher(SolidusSubscriptions::Dispatcher::SuccessDispatcher, installment) - it 'marks the installment to be reprocessed' do - subject - actionable_dates = installments.map do |installment| - installment.reload.actionable_date - end + described_class.new(installment).process - expect(actionable_dates).to all eq expected_date - end + expect(success_dispatcher).to have_received(:dispatch) end + end - context 'when there are cart promotions' do - let!(:promo) do - create( - :promotion, - :with_item_total_rule, - :with_order_adjustment, - promo_params - ) + context 'when payment of the order fails' do + it 'calls the payment failed dispatcher' do + stub_spree_preferences(auto_capture: true) + installment = create(:installment, :actionable).tap do |i| + i.subscription.update!(payment_source: create(:credit_card, number: '4111123412341234')) end + payment_failed_dispatcher = stub_dispatcher(SolidusSubscriptions::Dispatcher::PaymentFailedDispatcher, installment) - # Promotions require the :apply_automatically flag to be auto applied in - # solidus versions greater than 1.0 - let(:promo_params) do - {}.tap do |params| - if Spree::Promotion.new.respond_to?(:apply_automatically) - params[:apply_automatically] = true - end - end - end + described_class.new(installment).process - it_behaves_like 'a completed checkout' do - let(:total) { 39.98 } - end - - it 'applies the correct adjustments' do - expect(subject.adjustments).to be_present - end + expect(payment_failed_dispatcher).to have_received(:dispatch) end + end - context 'there is an aribitrary failure' do - let(:expected_date) { Time.zone.today + SolidusSubscriptions.configuration.reprocessing_interval } - - before do - allow(checkout).to receive(:populate).and_raise('arbitrary runtime error') - end - - it 'advances the installment actionable dates', :aggregate_failures do - expect { subject }.to raise_error('arbitrary runtime error') - - actionable_dates = installments.map do |installment| - installment.reload.actionable_date + context 'when an item is out of stock' do + it 'calls the out of stock dispatcher' do + stub_spree_preferences(auto_capture: true) + installment = create(:installment, :actionable).tap do |i| + i.subscription.line_items.first.subscribable.stock_items.each do |stock_item| + stock_item.update!(backorderable: false) end - - expect(actionable_dates).to all eq expected_date end - end - - context 'the user has store credit' do - let!(:store_credit) { create :store_credit, user: subscription_user } - let!(:store_credit_payment_method) { create :store_credit_payment_method } + out_of_stock_dispatcher = stub_dispatcher(SolidusSubscriptions::Dispatcher::OutOfStockDispatcher, installment) - it_behaves_like 'a completed checkout' + described_class.new(installment).process - it 'has a valid store credit payment' do - expect(order.payments.valid.store_credits).to be_present - end - end - - context 'the subscription has a shipping address' do - let(:installment_traits) do - { - subscription_traits: [{ - shipping_address: shipping_address, - user: subscription_user, - line_item_traits: [{ spree_line_item: root_order.line_items.first }] - }] - } - end - let(:shipping_address) { create :address } - - it_behaves_like 'a completed checkout' - - it 'ships to the subscription address' do - expect(subject.ship_address).to eq shipping_address - end - end - - context 'the subscription has a billing address' do - let(:installment_traits) do - { - subscription_traits: [{ - billing_address: billing_address, - user: subscription_user, - line_item_traits: [{ spree_line_item: root_order.line_items.first }] - }] - } - end - let(:billing_address) { create :address } - - it_behaves_like 'a completed checkout' - - it 'bills to the subscription address' do - expect(subject.bill_address).to eq billing_address - end - end - - context 'the subscription has a payment method' do - let(:installment_traits) do - { - subscription_traits: [{ - payment_method: payment_method, - user: subscription_user, - line_item_traits: [{ spree_line_item: root_order.line_items.first }] - }] - } - end - let(:payment_method) { create :check_payment_method } - - it_behaves_like 'a completed checkout' - - it 'pays with the payment method' do - expect(subject.payments.valid.first.payment_method).to eq payment_method - end - end - - context 'the subscription has a payment method and a source' do - let(:installment_traits) do - { - subscription_traits: [{ - payment_method: payment_method, - payment_source: payment_source, - user: subscription_user, - line_item_traits: [{ spree_line_item: root_order.line_items.first }] - }] - } - end - let(:payment_source) { create :credit_card, payment_method: payment_method, user: subscription_user } - let(:payment_method) { create :credit_card_payment_method } - - it_behaves_like 'a completed checkout' - - it 'pays with the payment method' do - expect(subject.payments.valid.first.payment_method).to eq payment_method - end - - it 'pays with the payment source' do - expect(subject.payments.valid.first.source).to eq payment_source - end + expect(out_of_stock_dispatcher).to have_received(:dispatch) end + end - context 'there are multiple associated subscritpion line items' do - it_behaves_like 'a completed checkout' do - let(:quantity) { subscription_line_items.length } - end + context 'when a generic transition error happens during checkout' do + it 'calls the failure dispatcher' do + stub_spree_preferences(auto_capture: true) + installment = create(:installment, :actionable) + failure_dispatcher = stub_dispatcher(SolidusSubscriptions::Dispatcher::FailureDispatcher, installment) + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Spree::Order).to receive(:next!) + .and_raise(StateMachines::InvalidTransition.new( + Spree::Order.new, + Spree::Order.state_machines[:state], + :next, + )) + # rubocop:enable RSpec/AnyInstance - let(:installments) { create_list(:installment, 1, installment_traits) } - let(:subscription_line_items) { create_list(:subscription_line_item, 2, quantity: 1) } + described_class.new(installment).process - let(:installment_traits) do - { - subscription_traits: [{ - user: subscription_user, - line_items: subscription_line_items - }] - } - end + expect(failure_dispatcher).to have_received(:dispatch) end end - describe '#order' do - subject { checkout.order } - - let(:user) { installments.first.subscription.user } - - it { is_expected.to be_a Spree::Order } - - it 'has the correct attributes' do - expect(subject).to have_attributes( - user: user, - email: user.email, - store: installments.first.subscription.store - ) - end + private - it 'is the same instance any time its called' do - order = checkout.order - expect(subject).to equal order + def stub_dispatcher(klass, installment) + instance_spy(klass).tap do |dispatcher| + allow(klass).to receive(:new).with( + [installment], + an_instance_of(Spree::Order) + ).and_return(dispatcher) end end end diff --git a/spec/services/solidus_subscriptions/order_builder_spec.rb b/spec/services/solidus_subscriptions/order_builder_spec.rb deleted file mode 100644 index 3463bd1..0000000 --- a/spec/services/solidus_subscriptions/order_builder_spec.rb +++ /dev/null @@ -1,53 +0,0 @@ -require 'spec_helper' - -RSpec.describe SolidusSubscriptions::OrderBuilder do - let(:builder) { described_class.new order } - - describe '#add_line_items' do - subject { builder.add_line_items([line_item]) } - - let(:variant) { create :variant, subscribable: true } - let(:order) do - create :order, line_items_attributes: line_items_attributes - end - - let(:line_item) { create(:line_item, quantity: 4, variant: variant) } - - context 'the line item doesnt already exist on the order' do - let(:line_items_attributes) { [] } - - it 'adds a new line item to the order' do - expect { subject }. - to change { order.line_items.count }. - from(0).to(1) - end - - it 'has a line item with the correct values' do - subject - line_item = order.line_items.last - - expect(line_item).to have_attributes( - variant_id: variant.id, - quantity: line_item.quantity - ) - end - end - - context 'the line item already exists on the order' do - let(:line_items_attributes) do - [{ - variant: variant, - quantity: 3 - }] - end - - it 'increases the correct line item by the correct amount' do - existing_line_item = order.line_items.first - - expect { subject }. - to change { existing_line_item.reload.quantity }. - by(line_item.quantity) - end - end - end -end |