Problem/Motivation
When a customer reaches the review step of checkout using the legacy Stripe Card Element, clicks "Pay", and the card is declined, the PaymentIntent is left in requires_payment_method status and Stripe marks the underlying PaymentMethod as consumed. The JS then displays the error and does not submit the form, so the server is unaware anything happened.
If the customer subsequently reloads the review page, StripeReview::buildPaneForm() runs again. The order still has its payment_method entity and its stripe_intent data, so the pane tries to update the intent.
Stripe rejects the update with:
The provided PaymentMethod was previously used with a PaymentIntent without Customer attachment, shared with a connected account without Customer attachment, or was detached from a Customer. It may not be used again. To use a PaymentMethod multiple times, you must attach it to a Customer first.
That call is not wrapped in a try/catch, so Stripe\Exception\InvalidRequestException propagates uncaught and Drupal renders a WSOD.
Steps to reproduce
- Configure a Stripe Card Element gateway in test mode and a checkout flow with the standard Order information / Review steps.
- Add a product to the cart and proceed to checkout.
- On /order_information, enter a Stripe test card that fails on confirmation rather than at PaymentMethod creation, e.g.
4000 0000 0000 9995declined "insufficient funds" on confirm. - Submit the form. You are redirected to /review.
- Click Pay. An inline error message is shown.
- Reload the page.
- Drupal shows WSOD. Drupal log shows an uncaught
Stripe\Exception\InvalidRequestExceptionwith the "PaymentMethod was previously used …" record.
Proposed resolution
Wrap the PaymentIntent::update() in a try / catch . On any error from that update cancel the stale intent, clear stripe_intent from the order, clear the order's payment_method reference, log the original Stripe error, and redirect the customer back to the order_information step with a message instead of throwing.
Remaining tasks
User interface changes
Customers who reload /review after a failed Stripe.js confirmation will see the order-information step again with a Drupal status/error message instead of a 500 / WSOD.
API changes
None.
Data model changes
None.
Issue fork commerce_stripe-3586894
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #3
alex.bukach commentedComment #4
anybodyThank you very much @alex.bukach. Makes a lot of sense to me. For the details I think the maintainers need to take a look, but I left two general comments for now.
Logically LGTM.
Comment #5
rhovlandChanging the priority of this because this results in a WSOD that cannot be resolved by the customer because they will always be sent to the review step where the API call runs and fails again so this halts checkout permanently for anonymous checkout customers.
Also while it's easiest to trigger the error with card element gateway, this error can happen to the Payment Element gateway too if for any reason the API call to update the PaymentIntent fails.
Comment #6
rhovlandCleaned up the code, removed the unnecessary try/catch for setting the payment method to non reusable.
Tried to add logging for the intent cancel catch but I'm not sure if I can nest them like that with the $e variable so I just did a more generic error message with the intent id and order id.
Comment #7
rhovlandI assumed that with $logger being in the class it had been setup. That was incomplete code from another commit. The service has now been setup properly.
Testing this out some, it seems like canceling the payment intent here is unnecessary as it automatically gets canceled by an event subscriber once the order is saved and information step is loaded again.
So this whole code block can go away
Removing the payment method from the order is definitely necessary. Setting the payment method as not reusable is not enough and it will remain attached to the order causing a different error from stripe about the payment intent missing a payment method.
Comment #8
rhovlandCode is at a place I feel this is usable in production.
For mantainers: If you're wondering if setting the payment method to be not reusable here is a good idea consider that this code path will only ever be triggered when Stripe considers a payment method to be not reusable which as far as I know only happens when it's not attached to a customer. So this won't go and mark returning customer's payment methods as not reusable because they had insufficient funds or failed 3DS authentication. It only affects payment methods stripe already considers to be not reusable.
Comment #9
anybodyThanks @rhovland that makes a lot of sense to me! Great to see this progress, I hope maintainers will have time soon to review this code soon. We'll also check out, if it solves (some of) our present issues.
Comment #10
rhovlandI'm surprised this issue hadn't already been addressed. 1 in every 20th anonymous customer hits this bug on our site. It is not a fringe bug that gets triggered rarely.
Comment #11
rhovlandDone a bunch of testing on these changes:
Anonymous customer fails due to 3DS/insufficient funds:
All of the operations as a logged in customer work the same as before the changes.
Comment #12
alex.bukach commented@rhovland thanks for review and testing! Your changes make sense for me.