fix(place-order): place the order when the shopper returns from a payment redirect - #831
Open
acasazza wants to merge 4 commits into
Open
fix(place-order): place the order when the shopper returns from a payment redirect#831acasazza wants to merge 4 commits into
acasazza wants to merge 4 commits into
Conversation
✅ Deploy Preview for commercelayer-react-components ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
…irect Async payments (PayPal, Klarna, 3DS) authorized on the gateway but left the order at pending + authorized: `_place` never fired once the shopper was back on the return url. Five separate conditions in <PlaceOrderButton> could stop it. - The Adyen fallback branches were gated on `!options.adyen.redirectResult`, but that parameter stays in the url for the whole return. A reload, or a first attempt that did not go through, could therefore never place the order. Restructured the effect with early returns so the fallback is reached exactly when the redirect branch is not. - Replaced that gate with `isAuthorizedForThisOrder`, which also accepts core's own `payment_status === "authorized"`. The `merchantReference` check alone missed merchants who customize the merchant reference. An order-scoped signal is still required: a payment source cloned from the customer wallet carries the payment_response of an earlier order. - Stopped re-running the gateway widget's `onsubmit` on a redirect return. It started a second payment attempt, and every widget reports failure from it (Adyen's handleSubmit always returns false, Stripe's confirmPayment rejects an intent that already succeeded), which left isValid false. - Aligned the Adyen result codes: the effect accepted Authorised/Pending/ Received while handleClick's fallback only accepted Authorised, so async methods came back authorized and were never placed. The lists now live in #utils/paymentAuthorization and are shared with <AdyenPayment>. - Stripe now falls back to the order's own payment source, so a 3DS return no longer fails `(checkPaymentSource || isFree)` when the context has not hydrated yet. Guarded against the opposite failure: one automatic attempt per order per page load, and an explicit veto when the gateway reports Cancelled/Refused/Error or declined. Before this, a refused redirect was placed, because the widget's second submit answered true. Checkout.com is exempt, where placing a declined payment is deliberate. specs/orders/place-order.redirect.spec.tsx covers all of it: 16 tests, 9 of which fail against the previous behaviour. Also carries an unrelated CI change to the publish workflow: the Slack step moves to slackapi/slack-github-action@v3, which takes the webhook as an input (webhook, webhook-type) instead of the SLACK_WEBHOOK_URL and SLACK_WEBHOOK_TYPE env vars. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
acasazza
force-pushed
the
fix/place-order-redirect-return
branch
from
September 3, 2026 14:29
54666f1 to
c66d88a
Compare
commit: |
The redirect effect added in c66d88a does not place an order that was not placed before: it races the programmatic click the gateway widget fires once it has authorized the payment. Both callers reach `handleClick`, both of its status checks are async, so both read the order as still `pending` and place it. A second place repeats every side effect of `setPlaceOrder`. The one that shows is `_save_billing_address_to_customer_address_book`: the shopper ends up with the same address twice in their wallet, which is how this surfaced — mfe-checkout only pre-fills the customer step from a wallet holding exactly one address, so the duplicate read as an empty wallet and left the step open. `handleClick` is now a wrapper that serialises attempts per order, with the guard set synchronously before any await; the body moves to `placeOrderAttempt`. The guard sits where every automatic path converges, so it also covers the ones that did not cause this. It is reopened in a `finally`: an attempt that did not place must stay retryable by an explicit click, and one that did is stopped by the already-placed checks instead. `autoPlaceAttemptedRef` was not enough on its own — it only ever covered the fallback branch. The new spec holds the status lookup open to put both callers in flight together, and fails without the wrapper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
malessani
approved these changes
Sep 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Async payments (PayPal, Klarna, 3DS) authorize on the gateway, the shopper comes back to the return url, and the order is never placed: it sits at pending + authorized. Reported by a customer against v4 with ~141 affected orders since 1 June, and confirmed unchanged on v5.0.0 — the auto-place logic in
PlaceOrderButtonis byte-for-byte equivalent betweenv4.29.7andv5.0.0(the only diff in that area is Biome reformatting), and the same conditions are readable in the publisheddist/index.json npm.Five separate conditions could stop
_placefrom firing.1. The
redirectResultgate on the fallback branchesBoth Adyen fallback branches required
!options?.adyen?.redirectResult, but that parameter stays in the url for the entire return:cleanUrlBy()builds the return url by cutting at the first&, Adyen appendsredirectResultto it, and nothing rewrites history afterwards. So once the details had been submitted, a reload — or a first place attempt that did not go through — could never place the order again. The effect is now written with early returns, so the fallback is reached exactly when the redirect branch is not, and the gate is gone.2.
merchantReferenceas the only proof of authorizationThe gate that replaces it is
isAuthorizedForThisOrder, which also accepts core's ownpayment_status === "authorized". ThemerchantReference.includes(order.number)check alone silently excluded every merchant who customizes the merchant reference. An order-scoped signal is still required, and deliberately so: a payment source cloned from the customer's wallet carries thepayment_responseof the order it was first used on, so an authorized-looking response is not by itself proof that this order is paid.3. Re-running the widget's
onsubmiton a redirect returnhandleClickre-invoked the gateway widget'sonsubmitwhen the shopper was already back from the redirect. That starts a second payment attempt, and every widget reports failure from it — Adyen'shandleSubmitalways returnsfalse, Stripe'sconfirmPaymentrejects an intent that already succeeded — leavingisValid === false. Now skipped when returning from a redirect.Note the checks are on truthiness, not
!= null: integrators pass these options as empty strings when the shopper is not returning from a redirect (mfe-checkout does), so a null check here would skip the widget on the normal flow and nothing would ever be placed.4. Result-code lists that had drifted apart
The auto-place effect accepted
Authorised/Pending/Received, while the fallback insidehandleClickonly acceptedAuthorised. Async methods (Klarna, iDEAL) that come backPendingorReceivedwere therefore authorized and never placed. Both lists now come from#utils/paymentAuthorization, shared withAdyenPayment, which had its own inline copies.5. Stripe losing the payment source
On a 3DS return the Stripe effect fires as soon as
order.payment_sourceis present, which can be beforePaymentMethodContexthas hydratedpaymentSource.(checkPaymentSource || isFree)was then false and the order was silently never placed. It now falls back to the order's own payment source.Guards against the opposite failure
Placing an order whose payment is not authorized is worse than not placing it, so:
autoPlaceAttemptedRef), and the effect stands down while a place is already in flight.Cancelled/Refused/Erroror a declined status. This closes a hazard that existed before: a refused redirect was placed, because the widget's second submit answeredtrue. Checkout.com is exempt, where placing a declined payment is a deliberate feature.Tests
specs/orders/place-order.redirect.spec.tsx— 16 tests covering PayPal, Adyen (all three result codes, re-entry with and withoutredirectResult, customized merchant reference), Stripe 3DS, the normal non-redirect flow, and the guards.9 of them fail against the previous behaviour, including two that show a refused redirect being placed. Verified by reverting
src/and re-running. The guard tests arm a mounted widget on purpose: without one the button isdisabledandclick()is a no-op, so they would pass for the wrong reason.Verified against mfe-checkout
Built locally and wired in through
CL_RC_LOCAL_PATH, with the browser confirmed to be executing the localdist:payments-adyen-klarna(real Klarna Pay Later and Pay now redirects).payments-stripe-auto › guest with Klarna › success, is pre-existing: it fails identically with the library rebuilt without this change.