diff --git a/app/jobs/solidus_subscriptions/process_installment_job.rb b/app/jobs/solidus_subscriptions/process_installment_job.rb index 6beeec6a..17eb446c 100644 --- a/app/jobs/solidus_subscriptions/process_installment_job.rb +++ b/app/jobs/solidus_subscriptions/process_installment_job.rb @@ -5,7 +5,7 @@ class ProcessInstallmentJob < ApplicationJob 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 c40849a3..09b8f44a 100644 --- a/app/services/solidus_subscriptions/checkout.rb +++ b/app/services/solidus_subscriptions/checkout.rb @@ -1,155 +1,92 @@ # 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] 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] 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 - end + order = create_order + populate_order(order) + + if finalize_order(order) + SolidusSubscriptions + .configuration + .success_dispatcher_class + .new([installment], order) + .dispatch + + order + elsif order.payments.any?(&:failed?) + SolidusSubscriptions + .configuration + .payment_failed_dispatcher_class + .new([installment], order) + .dispatch - # 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 + unless installment.fulfilled? + SolidusSubscriptions + .configuration + .failure_dispatcher_class + .new([installment], order) + .dispatch end 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) + line_items = installment.line_item_builder.spree_line_items + + if line_items.empty? + SolidusSubscriptions + .configuration + .out_of_stock_dispatcher_class + .new([installment]) + .dispatch + end + + OrderBuilder.new(order).add_line_items(line_items) + order.recalculate + 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 - end end end diff --git a/spec/services/solidus_subscriptions/checkout_spec.rb b/spec/services/solidus_subscriptions/checkout_spec.rb index bd66bbc1..027662c3 100644 --- a/spec/services/solidus_subscriptions/checkout_spec.rb +++ b/spec/services/solidus_subscriptions/checkout_spec.rb @@ -1,389 +1,49 @@ -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/ - 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 - - it { is_expected.to be_complete } - - 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).to be_complete + expect(order).to be_paid end - 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 'copies basic information from the subscription' do + stub_spree_preferences(auto_capture: true) + installment = create(:installment, :actionable) + subscription = installment.subscription - it 'creates two failed installment details' do - expect { order }. - to change { SolidusSubscriptions::InstallmentDetail.count }. - by(installments.length) + order = described_class.new(installment).process - details = SolidusSubscriptions::InstallmentDetail.last(installments.length) - expect(details).to all be_failed - end - - it { is_expected.to be_nil } - - it 'creates no order' do - expect { subject }.not_to change { Spree::Order.count } - end - 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 - - after do - Spree::Order.checkout_flow(&@old_checkout_flow) - 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: 39.98, - shipment_total: 0 - ) - end - - it { is_expected.to be_complete } - end - end - - context 'the variant is out of stock' do - let(:subscription_line_item) { installments.last.subscription.line_items.first } - let(:expected_date) { (DateTime.current + SolidusSubscriptions.configuration.reprocessing_interval).beginning_of_minute } - - # 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 + 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 - 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) { (DateTime.current + SolidusSubscriptions.configuration.reprocessing_interval).beginning_of_minute } + it 'marks the order as a subscription order' do + stub_spree_preferences(auto_capture: true) + installment = create(:installment, :actionable) + subscription = installment.subscription - it { is_expected.to be_nil } + order = described_class.new(installment).process - 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 'marks the installment to be reprocessed' do - subject - actionable_dates = installments.map do |installment| - installment.reload.actionable_date - end - - expect(actionable_dates).to all eq expected_date - end + expect(order.subscription).to eq(subscription) + expect(order.subscription_order).to eq(true) end - context 'when there are cart promotions' do - let!(:promo) do - create( - :promotion, - :with_item_total_rule, - :with_order_adjustment, - promo_params - ) - end - - # 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 + it 'matches the total on the subscription' do + stub_spree_preferences(auto_capture: true) + installment = create(:installment, :actionable) + subscription = installment.subscription - 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 - end - - context 'there is an aribitrary failure' do - let(:expected_date) { (DateTime.current + SolidusSubscriptions.configuration.reprocessing_interval).beginning_of_minute } - - 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 - 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 } - - it_behaves_like 'a completed checkout' - - 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 - end - - context 'there are multiple associated subscritpion line items' do - it_behaves_like 'a completed checkout' do - let(:quantity) { subscription_line_items.length } - end - - let(:installments) { create_list(:installment, 1, installment_traits) } - let(:subscription_line_items) { create_list(:subscription_line_item, 2, quantity: 1) } - - let(:installment_traits) do - { - subscription_traits: [{ - user: subscription_user, - line_items: subscription_line_items - }] - } - end - 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 + order = described_class.new(installment).process - it 'is the same instance any time its called' do - order = checkout.order - expect(subject).to equal order + expect(order.item_total).to eq(subscription.line_items.first.subscribable.price) end end end