Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rescue from Spree::Order::InsufficientStock on frontend checkout flow #3023

Merged

Conversation

spaghetticode
Copy link
Member

@spaghetticode spaghetticode commented Jan 4, 2019

Ref #3020

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.

@spaghetticode spaghetticode force-pushed the issue-3020-availability-validations branch 2 times, most recently from eda8963 to ba0dff5 Compare January 4, 2019 14:13
kennyadsl
kennyadsl previously approved these changes Jan 4, 2019
Copy link
Member

@kennyadsl kennyadsl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @spaghetticode !

Copy link
Member

@kennyadsl kennyadsl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking twice, I left some comments. Let me know if they make sense, thanks!

@@ -161,6 +162,19 @@ def ensure_sufficient_stock_lines
end
end

def ensure_sufficient_shipment_stock_quantities
if @order.passed_checkout_step?('delivery')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is assuming the step after delivery is the last step, which is not the case for everyone since steps are configurable. Is there a way to refine this check in order to be more generic?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kennyadsl It's not necessarily assuming that the following step is the last... but it must be executed after the order passed the delivery step, for some reasons.

I'll share some context regarding why I added this if clause, I think it may help us in finding a better solution for the issue you exposed.

The reason is that, without that if, this spec fails. The spec seems to be still relevant to me, so adding the if allowed me to add the functionality while still keeping the existing behavior.

Today I tried to dig deeper in the issue regarding the spec mentioned above, and this is what I found out. After moving my before_action to the end of the chain things improved a little, but not enough.

The reason for having that specific if is that the before_action CheckoutController::before_payment may manipulate the order, specifically these lines:

        @differentiator = Spree::Stock::Differentiator.new(@order, packages)
        @differentiator.missing.each do |variant, quantity|
          @order.contents.remove(variant, quantity)
        end

Besides, the line @order.contents.remove(variant, quantity) has the side effect of changing the order state from payment to address 🤔. Have not checked why, but I guess some ActiveRecord callback is responsible for that.

Missing contents must be removed before running the new before_action, or behavior will change, and as said before, the removal happens only when the customer tries to see the checkout/payment page, which happens after delivery is completed. I think the state ordering delivery -> payment is quite robust, as generally order total should be calculated only after shipping charges and taxes are known, both depending on addresses. But YMMV, so robust may not be enough.

Another option would be to move the new code in the before_payment method, so it's executed when it starts to be relevant, but that would limit its execution only to that precise step, eventually limiting its usefulness, that's why I think this is not a viable solution.

The current solution has the strong disadvantage that @order.passed_checkout_step?('delivery') does not make clear why the step must be passed, and the strong connection with the existing before_payment method.

A different approach may be to leverage the existing code in before_payment and change the if to this, for example:

      packages = @order.shipments.map(&:to_package)
      @differentiator = Spree::Stock::Differentiator.new(@order, packages)
      if @differentiator.missing.empty?
      # ...

This way, there's no more explicit dependence on order steps and their names. What do you think?

flash[:error] = t('spree.inventory_error_flash_for_insufficient_shipment_quantity', unavailable_items: unavailable_items)
@order.restart_checkout_flow
@order.next!
redirect_to spree.checkout_state_path(:delivery)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is restarting the flow required? I think it will move the order to cart, and it's also not clear why next! is needed. What if we just push the user back to the delivery step?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the goal is to revert the order status to delivery, and there is no apparent need to restart from the cart state, but my guess is that using #restart_checkout_flow and add one #next! call may be better, at least in the long run, as #ensure_updated_shipments might develop in a more complex method that adds more (required) behavior to the restarting process. I'd rather follow the path than create a new one with code such as @order.update state: :delivery or someting similar.

On the other side what makes me frown a bit is the @order.next! line, as it may have one meaning today (advance the order state to delivery) but that can change in the future.

Maybe the best thing would be to restart_checkout_flow and remove the next!.

I'm not sure about what's the best solution, so I'm committed to following the advice of people with a better understanding of the checkout process and its intricacies.

@kennyadsl kennyadsl dismissed their stale review January 6, 2019 17:30

I found something that it's not clear on a second look

Copy link
Member

@kennyadsl kennyadsl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found a little issue but I think this works. Thanks @spaghetticode !

packages = @order.shipments.map(&:to_package)
if packages.empty?
flash[:error] = I18n.t('spree.insufficient_stock_for_order')
redirect_to cart_admin_order_url(@order)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This path should probably not contain _admin_. Also maybe we should use _path instead of _url, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch! 👍

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure how this could pass if the path in the controller was wrong, can you please double check?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specs pass because they don't exercise the path that was wrong. I'll fix the path and add another test for that branch of the if.

@spaghetticode spaghetticode force-pushed the issue-3020-availability-validations branch from 99b56ea to e24a08a Compare January 10, 2019 11:05
@spaghetticode
Copy link
Member Author

spaghetticode commented Jan 10, 2019

@kennyadsl I updated the code according to your comments, adding a couple of commits to facilitate your understanding of the changes.

When it's ok for you I'd like to rebase/squash and update the PR description/commit message in order to reflect the latest changes introduced. Just let me know when it's fine for you (or please add more comments if something is still not OK 😸 )

@kennyadsl
Copy link
Member

@spaghetticode go ahead with the commits squash!

@spaghetticode spaghetticode force-pushed the issue-3020-availability-validations branch from e24a08a to 73fffa6 Compare January 10, 2019 16:21
@spaghetticode spaghetticode changed the title Improve stock validations on frontend checkout flow Rescue from Spree::Order::InsufficientStock on frontend checkout flow Jan 10, 2019
Copy link
Member

@kennyadsl kennyadsl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

else
availability_validator = Spree::Stock::AvailabilityValidator.new
unless @order.line_items.all? { |item| availability_validator.validate(item) }
unavailable_items = @order.line_items.map(&:name).to_sentence
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@order.line_items.all? { |item| availability_validator.validate(item) } means that at least one line item is not valid, right? So, I'm not sure that unavailable_items = @order.line_items.map(&:name).to_sentence is right because it lists all line items as unavailable. Perhaps

unavailable_items = @order.line_items.select { |line_item| !availability_validator.validate(line_item) }.map(&:name).to_sentence

would do (haven't tested). WDYT?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, something went wrong with my cut & paste refactoring. It should be fine now, thank you for spotting the bug 🐛

@spaghetticode spaghetticode force-pushed the issue-3020-availability-validations branch from 73fffa6 to 32cbdc6 Compare January 12, 2019 21:51
redirect_to cart_path
else
availability_validator = Spree::Stock::AvailabilityValidator.new
unavailable_items = @order.line_items.select { |line_item| !availability_validator.validate(line_item) }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/InverseMethods: Use reject instead of inverting select.

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`.
@spaghetticode spaghetticode force-pushed the issue-3020-availability-validations branch from 32cbdc6 to fb9a631 Compare January 12, 2019 21:52
Copy link
Contributor

@ericsaupe ericsaupe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works for me. I think it's a little awkward for the user but it's better than throwing an exception to the user.

@kennyadsl kennyadsl merged commit b825223 into solidusio:master Jan 18, 2019
@kennyadsl kennyadsl deleted the issue-3020-availability-validations branch January 18, 2019 14:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
changelog:solidus_frontend Changes to the solidus_frontend gem type:bug Error, flaw or fault
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants