From fb9a6319160d7de562a6e86ead735d4913d700ea Mon Sep 17 00:00:00 2001 From: andrea longhi Date: Fri, 4 Jan 2019 14:43:05 +0100 Subject: [PATCH] Rescue from `InsufficientStock` error on `frontend` checkout flow When two users try to purchase the last item remaining from a non-backordeable stock location at the same time then the last one will experience an unhandled error `Spree::Order::InsufficientStock`. This happens only if there is a second backorderable stock location for the product. When there is no backorderable stock location the controller `before_action` `ensure_sufficient_stock_lines` is enough to catch the issue in advance. The error is generated by this line in Spree::Order model: `before_transition to: :complete, do: :validate_line_item_availability` Generally, `ensure_sufficient_stock_lines` prevent customers to complete the checkout process when there is not enough stock availability, but the case above is not caught here. So, by using `rescue_from` the customer is now redirected to the checkout `address` page and shown an error message that suggests to repeat the checkout process. The order's shipments will be rebuilt using the backorderable stock location in the delivery step, allowing them to (hopefully!) complete the purchase. `rescue_from` is already used on the `api` and `backend` section in order to manage `InsufficientStock` errors, so this was a natural choice also on the `frontend`. --- core/config/locales/en.yml | 1 + .../controllers/spree/checkout_controller.rb | 18 +++++ .../spree/checkout_controller_spec.rb | 39 ++++++++++ ...heckout_confirm_insufficient_stock_spec.rb | 71 +++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 frontend/spec/features/checkout_confirm_insufficient_stock_spec.rb diff --git a/core/config/locales/en.yml b/core/config/locales/en.yml index 93d2f2175a9..6e14f17259e 100644 --- a/core/config/locales/en.yml +++ b/core/config/locales/en.yml @@ -1472,6 +1472,7 @@ en: inventory_adjustment: Inventory Adjustment inventory_canceled: Inventory canceled inventory_error_flash_for_insufficient_quantity: "%{names} became unavailable." + inventory_error_flash_for_insufficient_shipment_quantity: "Quantity selected of %{unavailable_items} is not available. Still, items may be available from another stock location, please try again." inventory_not_available: Inventory not available for %{item}. inventory_state: Inventory State inventory_states: diff --git a/frontend/app/controllers/spree/checkout_controller.rb b/frontend/app/controllers/spree/checkout_controller.rb index 9d2652386e2..48955c33614 100644 --- a/frontend/app/controllers/spree/checkout_controller.rb +++ b/frontend/app/controllers/spree/checkout_controller.rb @@ -24,6 +24,7 @@ class CheckoutController < Spree::StoreController helper 'spree/orders' rescue_from Spree::Core::GatewayError, with: :rescue_from_spree_gateway_error + rescue_from Spree::Order::InsufficientStock, with: :insufficient_stock_error # Updates the order and advances to the next state (when possible.) def update @@ -233,5 +234,22 @@ def rescue_from_spree_gateway_error(exception) def check_authorization authorize!(:edit, current_order, cookies.signed[:guest_token]) end + + def insufficient_stock_error + packages = @order.shipments.map(&:to_package) + if packages.empty? + flash[:error] = I18n.t('spree.insufficient_stock_for_order') + redirect_to cart_path + else + availability_validator = Spree::Stock::AvailabilityValidator.new + unavailable_items = @order.line_items.reject { |line_item| availability_validator.validate(line_item) } + if unavailable_items.any? + item_names = unavailable_items.map(&:name).to_sentence + flash[:error] = t('spree.inventory_error_flash_for_insufficient_shipment_quantity', unavailable_items: item_names) + @order.restart_checkout_flow + redirect_to spree.checkout_state_path(@order.state) + end + end + end end end diff --git a/frontend/spec/controllers/spree/checkout_controller_spec.rb b/frontend/spec/controllers/spree/checkout_controller_spec.rb index 524147e833f..05a290e853a 100644 --- a/frontend/spec/controllers/spree/checkout_controller_spec.rb +++ b/frontend/spec/controllers/spree/checkout_controller_spec.rb @@ -420,6 +420,45 @@ def post_address expect(flash[:error]).to eq(I18n.t('spree.payment_processing_failed')) end end + + context "when InsufficientStock error is raised" do + before do + allow(controller).to receive_messages current_order: order + allow(controller).to receive_messages check_authorization: true + allow(controller).to receive_messages ensure_sufficient_stock_lines: true + end + + context "when the order has no shipments" do + let(:order) { Spree::TestingSupport::OrderWalkthrough.up_to(:address) } + + before do + allow(order).to receive_messages shipments: [] + # Order#next is the tipical failure point here: + allow(order).to receive(:next).and_raise(Spree::Order::InsufficientStock) + end + + it "redirects the customer to the cart page with an error message" do + put :update, params: { state: order.state, order: {} } + expect(flash[:error]).to eq(I18n.t('spree.insufficient_stock_for_order')) + expect(response).to redirect_to(spree.cart_path) + end + end + + context "when the order has shipments" do + let(:order) { Spree::TestingSupport::OrderWalkthrough.up_to(:payment) } + + context "when items become somehow not available anymore" do + before { Spree::StockItem.update_all backorderable: false } + + it "redirects the customer to the address checkout page with an error message" do + put :update, params: { state: order.state, order: {} } + error = I18n.t('spree.inventory_error_flash_for_insufficient_shipment_quantity', unavailable_items: order.products.first.name) + expect(flash[:error]).to eq(error) + expect(response).to redirect_to(spree.checkout_state_path(state: :address)) + end + end + end + end end context "When last inventory item has been purchased" do diff --git a/frontend/spec/features/checkout_confirm_insufficient_stock_spec.rb b/frontend/spec/features/checkout_confirm_insufficient_stock_spec.rb new file mode 100644 index 00000000000..0a02084b24f --- /dev/null +++ b/frontend/spec/features/checkout_confirm_insufficient_stock_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe "Checkout confirm page submission", type: :feature do + include_context 'checkout setup' + + context "when the product from the order is not backorderable but has enough stock quantity" do + let(:user) { create(:user) } + + let(:order) { Spree::TestingSupport::OrderWalkthrough.up_to(:payment) } + let(:order_product) { order.products.first } + let(:order_stock_item) { order_product.stock_items.first } + + before do + order_stock_item.update! backorderable: false + order_stock_item.set_count_on_hand(1) + allow_any_instance_of(Spree::CheckoutController).to receive_messages(current_order: order) + allow_any_instance_of(Spree::CheckoutController).to receive_messages(try_spree_current_user: user) + allow_any_instance_of(Spree::OrdersController).to receive_messages(try_spree_current_user: user) + end + + context 'when there are not other backorderable stock locations' do + context 'when the customer is on the confirm page and the availabilty drops to zero' do + before do + visit spree.checkout_state_path(:confirm) + order_stock_item.set_count_on_hand(0) + end + + it 'redirects to cart page and shows an unavailable product message' do + click_button "Place Order" + expect(page).to have_content "#{order_product.name} became unavailable" + expect(page).to have_current_path spree.cart_path + end + end + end + + context 'when there is another backorderable stock location' do + before do + create :stock_location, backorderable_default: true, default: false + end + + context 'when the customer is on the confirm page and the availabilty drops to zero' do + before do + visit spree.checkout_state_path(:confirm) + order_stock_item.set_count_on_hand(0) + end + + it "redirects to the address checkout page and shows an availability changed message" do + click_button "Place Order" + error_message = "Quantity selected of #{order_product.name} is not available. Still, items may be available from another stock location, please try again." + expect(page).to have_content error_message + expect(page).to have_current_path spree.checkout_state_path(:address) + end + + it "can still complete the order using the backorderable stock location by restarting the checkout" do + click_button "Place Order" + expect(page).to have_current_path spree.checkout_state_path(:address) + click_button "Save and Continue" + expect(page).to have_current_path spree.checkout_state_path(:delivery) + click_button "Save and Continue" + expect(page).to have_current_path spree.checkout_state_path(:payment) + click_button "Save and Continue" + expect(page).to have_current_path spree.checkout_state_path(:confirm) + click_button "Place Order" + expect(page).to have_content 'Your order has been processed successfully' + end + end + end + end +end