Skip to content

fix(place-order): place the order when the shopper returns from a payment redirect - #831

Open
acasazza wants to merge 4 commits into
mainfrom
fix/place-order-redirect-return
Open

fix(place-order): place the order when the shopper returns from a payment redirect#831
acasazza wants to merge 4 commits into
mainfrom
fix/place-order-redirect-return

Conversation

@acasazza

@acasazza acasazza commented Sep 3, 2026

Copy link
Copy Markdown
Member

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 PlaceOrderButton is byte-for-byte equivalent between v4.29.7 and v5.0.0 (the only diff in that area is Biome reformatting), and the same conditions are readable in the published dist/index.js on npm.

Five separate conditions could stop _place from firing.

1. The redirectResult gate on the fallback branches

Both 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 appends redirectResult to 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. merchantReference as the only proof of authorization

The gate that replaces it is isAuthorizedForThisOrder, which also accepts core's own payment_status === "authorized". The merchantReference.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 the payment_response of 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 onsubmit on a redirect return

handleClick re-invoked the gateway widget's onsubmit when the shopper was already back from the redirect. That starts 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 — leaving isValid === 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 inside handleClick only accepted Authorised. Async methods (Klarna, iDEAL) that come back Pending or Received were therefore authorized and never placed. Both lists now come from #utils/paymentAuthorization, shared with AdyenPayment, 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_source is present, which can be before PaymentMethodContext has hydrated paymentSource. (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:

  • One automatic attempt per order per page load (autoPlaceAttemptedRef), and the effect stands down while a place is already in flight.
  • An explicit veto when the gateway reports Cancelled/Refused/Error or a declined status. This closes a hazard that existed before: a refused redirect was placed, because the widget's second submit answered true. 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 without redirectResult, 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 is disabled and click() 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 local dist:

  • No regressions: 32 passed / 3 skipped across Adyen, Adyen-customer, PayPal, Stripe, Stripe-auto, Checkout.com and Klarna, plus 3 passed on payments-adyen-klarna (real Klarna Pay Later and Pay now redirects).
  • The single failure, payments-stripe-auto › guest with Klarna › success, is pre-existing: it fails identically with the library rebuilt without this change.
  • Pre-fix, an e2e reproduction showed the browser never retrying after a reload of the return url — the bug observed in the app, not only in unit tests. The post-fix recovery could not be demonstrated end to end in that org, because the order gets placed server-side before the state can be held.

@acasazza acasazza added the bug Something isn't working label Sep 3, 2026
@acasazza acasazza self-assigned this Sep 3, 2026
@netlify

netlify Bot commented Sep 3, 2026

Copy link
Copy Markdown

Deploy Preview for commercelayer-react-components ready!

Name Link
🔨 Latest commit 565cee4
🔍 Latest deploy log https://app.netlify.com/projects/commercelayer-react-components/deploys/6a9ac80e4d45fe0008dfe141
😎 Deploy Preview https://deploy-preview-831--commercelayer-react-components.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

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
acasazza force-pushed the fix/place-order-redirect-return branch from 54666f1 to c66d88a Compare September 3, 2026 14:29
@pkg-pr-new

pkg-pr-new Bot commented Sep 3, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/@commercelayer/core-components@831
npm i https://pkg.pr.new/@commercelayer/react-components@831
npm i https://pkg.pr.new/@commercelayer/react-hooks-components@831

commit: 565cee4

Alessandro Casazza and others added 3 commits September 3, 2026 16:56
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants