diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index d668f1c5..f22874a9 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -51,8 +51,10 @@ jobs: - name: Post to a Slack channel id: slack - uses: slackapi/slack-github-action@v1.25.0 + uses: slackapi/slack-github-action@v3.0.1 with: + webhook: ${{ secrets.PIPELINE_SLACK_CHANNEL_WEBHOOK_URL }} + webhook-type: incoming-webhook # Slack channel id, channel name, or user id to post message. # See also: https://api.slack.com/methods/chat.postMessage#channels # channel-id: 'pipeline' @@ -97,6 +99,3 @@ jobs: } ] } - env: - SLACK_WEBHOOK_URL: ${{ secrets.PIPELINE_SLACK_CHANNEL_WEBHOOK_URL }} - SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK \ No newline at end of file diff --git a/lerna.json b/lerna.json index 11749946..f430d1c0 100644 --- a/lerna.json +++ b/lerna.json @@ -2,7 +2,7 @@ "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useNx": false, "npmClient": "pnpm", - "version": "5.0.0", + "version": "5.0.1-beta.1", "command": { "version": { "preid": "beta" diff --git a/packages/core-components/package.json b/packages/core-components/package.json index d7cedb98..a9718191 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -1,6 +1,6 @@ { "name": "@commercelayer/core-components", - "version": "5.0.0", + "version": "5.0.1-beta.0", "description": "Commerce Layer Core", "type": "module", "main": "./dist/index.js", diff --git a/packages/react-components/package.json b/packages/react-components/package.json index 96583980..bb7be11e 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -1,6 +1,6 @@ { "name": "@commercelayer/react-components", - "version": "5.0.0", + "version": "5.0.1-beta.1", "description": "The Official Commerce Layer React Components", "type": "module", "main": "./dist/index.js", diff --git a/packages/react-components/specs/orders/place-order.redirect.spec.tsx b/packages/react-components/specs/orders/place-order.redirect.spec.tsx new file mode 100644 index 00000000..e9227fe6 --- /dev/null +++ b/packages/react-components/specs/orders/place-order.redirect.spec.tsx @@ -0,0 +1,571 @@ +/** + * Regression suite for placing the order when the shopper comes back from a + * payment redirect (PayPal, Adyen 3DS/APM, Stripe 3DS). + * + * Every case here used to end with the order stranded at pending + authorized: + * the payment went through, `_place` never fired, and nothing on the page said + * so. The `NEVER placed` cases at the bottom are the guards that must survive + * the fix — placing an order whose payment is not authorized is worse than not + * placing it. + */ +import { render, waitFor } from "@testing-library/react" +import { type ReactNode, useRef } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { PlaceOrderButton } from "#components/orders/PlaceOrderButton" +import CommerceLayerContext from "#context/CommerceLayerContext" +import CustomerContext from "#context/CustomerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import PaymentMethodContext, { defaultPaymentMethodContext } from "#context/PaymentMethodContext" +import PlaceOrderContext, { defaultPlaceOrderContext } from "#context/PlaceOrderContext" + +const ordersRetrieve = vi + .fn() + .mockResolvedValue({ id: "order-1", status: "pending", payment_status: "authorized" }) + +vi.mock("@commercelayer/core-components", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getSdk: vi.fn().mockReturnValue({ + orders: { + retrieve: (...args: unknown[]) => ordersRetrieve(...args), + update: vi.fn().mockResolvedValue({ id: "order-1", status: "placed" }), + }, + }), + } +}) + +vi.mock("#utils/organization", () => ({ + useOrganizationConfig: vi.fn().mockReturnValue(null), +})) + +// The Stripe redirect effect verifies the payment intent before placing. +vi.mock("#utils/stripe/retrievePaymentIntent", () => ({ + checkPaymentIntent: vi.fn().mockResolvedValue({ status: "valid" }), +})) + +// No card brand: Klarna / PayPal / APMs have none, so nothing else can flip +// `isValid` to true behind the scenes. +vi.mock("#utils/getCardDetails", () => ({ + default: vi.fn().mockReturnValue({ brand: "" }), +})) + +// biome-ignore lint/suspicious/noExplicitAny: test cast +function makeOrder(paymentResponse: any, paymentSourceExtra: Record = {}): any { + return { + id: "order-1", + number: "1234", + status: "pending", + payment_status: "authorized", + total_amount_with_taxes_cents: 1000, + payment_method: { id: "pm-1", payment_source_type: "adyen_payments" }, + payment_source: { + id: "ps-1", + type: "adyen_payments", + payment_response: paymentResponse, + ...paymentSourceExtra, + }, + billing_address: { id: "ba-1" }, + shipping_address: { id: "sa-1" }, + shipments: [], + line_items: [], + } +} + +/** Payment source cloned from the customer wallet: details and an authorized response from an earlier order. */ +const REDIRECT_DETAILS = { + payment_request_details: { details: { redirectResult: "REDIRECT-RESULT" } }, +} + +function Harness({ + order, + paymentType, + options, + setPlaceOrder, + setPaymentSource, + onsubmit, + status = "standby", + paymentSource = { id: "ps-1", type: "adyen_payments" }, + captureHandleClick, +}: { + // biome-ignore lint/suspicious/noExplicitAny: test cast + order: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + paymentType: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + options: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + setPlaceOrder: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + setPaymentSource: any + /** Pass a mock to simulate a mounted gateway widget that patched `onsubmit`. */ + // biome-ignore lint/suspicious/noExplicitAny: test cast + onsubmit?: any + status?: "standby" | "placing" | "disabled" + // biome-ignore lint/suspicious/noExplicitAny: test cast + paymentSource?: any + /** + * Hands the button's own `handleClick` back to the test, so a second caller + * can be fired at it the way the gateway widget fires a programmatic click. + */ + captureHandleClick?: (handleClick: () => Promise) => void +}): ReactNode { + const formRef = useRef(null) + if (onsubmit != null && formRef.current == null) { + // biome-ignore lint/suspicious/noExplicitAny: test cast + formRef.current = { onsubmit } as any + } + return ( + + + + + + {captureHandleClick != null ? ( + + {({ handleClick }) => { + captureHandleClick(handleClick) + return + }} + + ) : ( + + )} + + + + + + ) +} + +function placeOrderSpy(): ReturnType { + return vi.fn().mockResolvedValue({ placed: true }) +} + +/** A mounted widget always reports failure when re-submitted after a redirect. */ +function widgetOnsubmit(): ReturnType { + return vi.fn().mockResolvedValue(false) +} + +async function settle(): Promise { + await new Promise((r) => setTimeout(r, 300)) +} + +describe("place order on redirect return", () => { + beforeEach(() => { + vi.clearAllMocks() + ordersRetrieve.mockResolvedValue({ + id: "order-1", + status: "pending", + payment_status: "authorized", + }) + }) + + it("PayPal return (PayerID in the url) places the order", async () => { + const setPlaceOrder = placeOrderSpy() + const order = makeOrder(undefined) + order.payment_method.payment_source_type = "paypal_payments" + order.payment_source.type = "paypal_payments" + render( + + ) + await waitFor(() => { + expect(setPlaceOrder).toHaveBeenCalled() + }) + }) + + it.each(["Authorised", "Pending", "Received"])( + "Adyen redirect returning %s places the order even with the widget re-armed", + async (resultCode) => { + const setPlaceOrder = placeOrderSpy() + const onsubmit = widgetOnsubmit() + render( + + ) + await waitFor(() => { + expect(setPlaceOrder).toHaveBeenCalled() + }) + // The shopper already paid on the redirect: submitting the widget again + // would start a second payment attempt. + expect(onsubmit).not.toHaveBeenCalled() + } + ) + + it("re-entry on the return url (details already submitted) places the order", async () => { + const setPlaceOrder = placeOrderSpy() + render( + + ) + await waitFor(() => { + expect(setPlaceOrder).toHaveBeenCalled() + }) + }) + + it("re-entry on a clean url places the order", async () => { + const setPlaceOrder = placeOrderSpy() + render( + + ) + await waitFor(() => { + expect(setPlaceOrder).toHaveBeenCalled() + }) + }) + + it("a customized merchantReference does not stop the place when core says authorized", async () => { + const setPlaceOrder = placeOrderSpy() + render( + + ) + await waitFor(() => { + expect(setPlaceOrder).toHaveBeenCalled() + }) + }) + + it("only one automatic attempt per order", async () => { + const setPlaceOrder = placeOrderSpy() + render( + + ) + await waitFor(() => { + expect(setPlaceOrder).toHaveBeenCalled() + }) + await settle() + expect(setPlaceOrder).toHaveBeenCalledTimes(1) + }) + + it("a click landing while the automatic attempt is in flight places the order once", async () => { + const setPlaceOrder = placeOrderSpy() + /** + * The automatic effect and the programmatic click the gateway widget fires + * after authorizing both reach `handleClick`, and both of its status checks + * are async. Holding the status lookup open puts them in flight together, + * which is the window that placed the order twice: two `_place` calls, and + * with them two `_save_billing_address_to_customer_address_book` calls, so + * the shopper ended up with the same address twice in their wallet. + */ + let release: () => void = () => {} + const lookup = new Promise((resolve) => { + release = resolve + }) + ordersRetrieve.mockImplementation(async () => { + await lookup + return { id: "order-1", status: "pending", payment_status: "authorized" } + }) + let handleClick: (() => Promise) | null = null + render( + { + handleClick = fn + }} + /> + ) + await waitFor(() => { + expect(ordersRetrieve).toHaveBeenCalled() + }) + // The widget clicks while the first attempt is still waiting on the lookup. + const click = handleClick as unknown as () => Promise + const second = click() + release() + await second + await settle() + expect(setPlaceOrder).toHaveBeenCalledTimes(1) + }) + + it("Stripe 3DS return places the order before the context payment source hydrates", async () => { + const setPlaceOrder = placeOrderSpy() + const order = makeOrder(undefined) + order.payment_method.payment_source_type = "stripe_payments" + order.payment_source = { id: "ps-1", type: "stripe_payments", publishable_key: "pk_test" } + render( + + ) + await waitFor(() => { + expect(setPlaceOrder).toHaveBeenCalled() + }) + }) + + it("Stripe 3DS return does not confirm the payment intent a second time", async () => { + const setPlaceOrder = placeOrderSpy() + const onsubmit = widgetOnsubmit() + const order = makeOrder(undefined) + order.payment_method.payment_source_type = "stripe_payments" + order.payment_source = { id: "ps-1", type: "stripe_payments", publishable_key: "pk_test" } + render( + + ) + await waitFor(() => { + expect(setPlaceOrder).toHaveBeenCalled() + }) + expect(onsubmit).not.toHaveBeenCalled() + }) + + it("the normal flow still lets the widget validate the payment", async () => { + const setPlaceOrder = placeOrderSpy() + // Integrators pass empty strings when the shopper is not returning from a redirect. + const onsubmit = vi.fn().mockResolvedValue(true) + const { getByRole } = render( + + ) + getByRole("button").click() + await waitFor(() => { + expect(onsubmit).toHaveBeenCalled() + }) + await waitFor(() => { + expect(setPlaceOrder).toHaveBeenCalled() + }) + }) + + it("Checkout.com still places an order whose payment was declined", async () => { + const setPlaceOrder = placeOrderSpy() + const order = makeOrder({ status: "Declined" }) + order.payment_method.payment_source_type = "checkout_com_payments" + order.payment_status = "unpaid" + const { getByRole } = render( + + ) + getByRole("button").click() + await waitFor(() => { + expect(setPlaceOrder).toHaveBeenCalled() + }) + }) + + describe("guards that must keep the order unplaced", () => { + it("an authorized response from an earlier order (cloned wallet source) is NEVER placed", async () => { + const setPlaceOrder = placeOrderSpy() + // merchantReference points at another order and core has not authorized this one. + const order = makeOrder( + { resultCode: "Authorised", merchantReference: "9999" }, + REDIRECT_DETAILS + ) + order.payment_status = "unpaid" + render( + + ) + await settle() + expect(setPlaceOrder).not.toHaveBeenCalled() + }) + + it("a refused redirect is NEVER placed, even on a manual click", async () => { + const setPlaceOrder = placeOrderSpy() + const order = makeOrder({ resultCode: "Refused" }) + order.payment_status = "unpaid" + const { getByRole } = render( + + ) + getByRole("button").click() + await settle() + // The button was live and the click did reach handleClick: only the veto + // stopped it, not a disabled button. + expect(getByRole("button").hasAttribute("disabled")).toBe(false) + expect(ordersRetrieve).toHaveBeenCalled() + expect(setPlaceOrder).not.toHaveBeenCalled() + }) + + it("a declined payment response is NEVER placed on a redirect return", async () => { + const setPlaceOrder = placeOrderSpy() + const order = makeOrder({ status: "Declined" }) + order.payment_status = "unpaid" + const { getByRole } = render( + + ) + getByRole("button").click() + await settle() + // The button was live and the click did reach handleClick: only the veto + // stopped it, not a disabled button. + expect(getByRole("button").hasAttribute("disabled")).toBe(false) + expect(ordersRetrieve).toHaveBeenCalled() + expect(setPlaceOrder).not.toHaveBeenCalled() + }) + + it("a placed order is not placed again", async () => { + const setPlaceOrder = placeOrderSpy() + ordersRetrieve.mockResolvedValue({ + id: "order-1", + status: "placed", + payment_status: "authorized", + }) + render( + + ) + await settle() + expect(setPlaceOrder).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/react-components/src/components/orders/PlaceOrderButton.tsx b/packages/react-components/src/components/orders/PlaceOrderButton.tsx index 3c5626b9..180d277f 100644 --- a/packages/react-components/src/components/orders/PlaceOrderButton.tsx +++ b/packages/react-components/src/components/orders/PlaceOrderButton.tsx @@ -18,6 +18,7 @@ import type { PlaceOrderOptions } from "#reducers/PlaceOrderReducer" import type { BaseError } from "#typings/errors" import type { ChildrenFunction } from "#typings/index" import getCardDetails from "#utils/getCardDetails" +import { isAdyenAuthorizedResultCode, isRefusedPaymentResponse } from "#utils/paymentAuthorization" import { checkPaymentIntent } from "#utils/stripe/retrievePaymentIntent" import Parent from "../utils/Parent" @@ -55,6 +56,10 @@ interface Props extends Omit(null) + /** Order id a place attempt is currently in flight for. */ + const placeInFlightRef = useRef(null) const { children, label = "Place order", @@ -234,86 +239,91 @@ export function PlaceOrderButton(props: Props): JSX.Element { order?.payment_source != null, ]) useEffect(() => { - if (order?.status != null && ["draft", "pending"].includes(order?.status)) { - // Adyen redirect flow - const isAuthorized = + // Adyen redirect flow + if (order?.status == null || !["draft", "pending"].includes(order.status)) return + if (paymentType !== "adyen_payments" || !autoPlaceOrder) return + const paymentResponse = + // @ts-expect-error no type + order?.payment_source?.payment_response + const paymentDetails = + // @ts-expect-error no type + order?.payment_source?.payment_request_details?.details != null + // NOTE: truthiness, not `!= null`: integrators pass `redirectResult` as an + // empty string when the shopper is *not* coming back from a redirect. + if (options?.adyen?.redirectResult && !paymentDetails) { + const attributes = { + payment_request_details: { + details: { + redirectResult: options?.adyen?.redirectResult, + }, + }, + _details: 1, + } + setPaymentSource({ + paymentSourceId: paymentSource?.id, + paymentResource: "adyen_payments", + attributes, + }).then((res) => { // @ts-expect-error no type - order?.payment_source?.payment_response?.resultCode === "Authorised" - const paymentDetails = + const resultCode: string = res?.payment_response?.resultCode // @ts-expect-error no type - order?.payment_source?.payment_request_details?.details != null - const paymentStatus = order?.payment_status - const paymentMethodType = + const errorCode = res?.payment_response?.errorCode // @ts-expect-error no type - order?.payment_source?.payment_response?.paymentMethod?.type - if (paymentType === "adyen_payments" && options?.adyen?.redirectResult && !paymentDetails) { - const attributes = { - payment_request_details: { - details: { - redirectResult: options?.adyen?.redirectResult, - }, - }, - _details: 1, - } - setPaymentSource({ - paymentSourceId: paymentSource?.id, - paymentResource: "adyen_payments", - attributes, - }).then((res) => { - // @ts-expect-error no type - const resultCode: string = res?.payment_response?.resultCode - // @ts-expect-error no type - const errorCode = res?.payment_response?.errorCode - // @ts-expect-error no type - const message = res?.payment_response?.message - if (["Authorised", "Pending", "Received"].includes(resultCode) && autoPlaceOrder) { - handleClick() - } else if (errorCode != null) { - setPaymentMethodErrors([ - { - code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", - resource: "payment_methods", - field: currentPaymentMethodType, - message, - }, - ]) - } - }) - } else if ( - paymentType === "adyen_payments" && - isAuthorized && - paymentDetails && - autoPlaceOrder && - status === "standby" && - !options?.adyen?.redirectResult - ) { - // NOTE: This is a workaround for the case when the user reloads the page after selecting a customer payment source - if ( - // @ts-expect-error no type - order?.payment_source?.payment_response?.merchantReference?.includes(order?.number) - ) { - handleClick() - } - } else if ( - paymentType === "adyen_payments" && - isAuthorized && - paymentStatus === "authorized" && - paymentMethodType === "giftcard" && - autoPlaceOrder && - status === "standby" && - !options?.adyen?.redirectResult - ) { - // NOTE: This is a workaround for the case when the user reloads the page after selecting a customer payment source - if ( - // @ts-expect-error no type - order?.payment_source?.payment_response?.merchantReference?.includes(order?.number) - ) { + const message = res?.payment_response?.message + if (isAdyenAuthorizedResultCode(resultCode)) { handleClick() + } else if (errorCode != null) { + setPaymentMethodErrors([ + { + code: "PAYMENT_INTENT_AUTHENTICATION_FAILURE", + resource: "payment_methods", + field: currentPaymentMethodType, + message, + }, + ]) } - } + }) + return + } + /** + * The payment is authorized but the order is still pending. We get here when + * the details for this redirect were already submitted — the shopper reloaded + * the return URL, came back to it later, or a first place attempt did not go + * through. Retrying is what keeps the order from being stranded at + * pending + authorized, so this must NOT be gated on the absence of + * `redirectResult`: that parameter is still in the URL for the whole return. + * + * `isAuthorizedForThisOrder` is the guard that replaces it. 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. Either of two order-scoped signals + * is: core reporting `payment_status === "authorized"`, or Adyen echoing this + * order's number in `merchantReference`. The first covers merchants who + * customize the merchant reference, which the reference check alone missed. + */ + const isAuthorizedForThisOrder = + order.payment_status === "authorized" || + (order.number != null && paymentResponse?.merchantReference?.includes(order.number) === true) + if ( + isAdyenAuthorizedResultCode(paymentResponse?.resultCode) && + isAuthorizedForThisOrder && + // A place is already in flight; `status` returns to standby if it fails. + status !== "placing" && + // One automatic attempt per order per page load: `handleClick` flips + // `status`, which re-runs this effect. + autoPlaceAttemptedRef.current !== order.id + ) { + autoPlaceAttemptedRef.current = order.id + handleClick() } }, [ - options?.adyen?.redirectResult != null, + order?.id, + order?.status, + order?.payment_status, + order?.number, + Boolean(options?.adyen?.redirectResult), + paymentType, + status, // @ts-expect-error no type order?.payment_source?.payment_response?.resultCode, ]) @@ -419,9 +429,7 @@ export function PlaceOrderButton(props: Props): JSX.Element { // to be enabled on mount regardless of whether a payment method was selected. } }, [status]) - const handleClick = async (e?: MouseEvent): Promise => { - e?.preventDefault() - e?.stopPropagation() + const placeOrderAttempt = async (): Promise => { const sdk = sdkClient() if (sdk == null) return if (order == null) return @@ -489,7 +497,12 @@ export function PlaceOrderButton(props: Props): JSX.Element { paymentResource: paymentType, paymentSourceId: paymentSource?.id, }) - : paymentSource + : // Fall back to the order's own payment source: on a 3DS return the + // Stripe redirect effect above fires as soon as `order.payment_source` + // is there, which can be before `PaymentMethodContext` has hydrated + // `paymentSource`. Without the fallback `(checkPaymentSource || isFree)` + // was false and the order was silently never placed. + (paymentSource ?? (order?.payment_source as typeof paymentSource)) const checkPaymentSourceStatus = // @ts-expect-error no type checkPaymentSource?.payment_response?.status?.toLowerCase?.() @@ -499,12 +512,28 @@ export function PlaceOrderButton(props: Props): JSX.Element { paymentType, customerPayment: { payment_source: checkPaymentSource }, }) - if ( - currentPaymentMethodRef?.current?.onsubmit && - [!options?.paypalPayerId, !options?.adyen?.MD, !options?.checkoutCom?.session_id].every( - Boolean - ) - ) { + /** + * Coming back from a payment redirect (PayPal, Adyen 3DS/APM, Checkout.com, + * Stripe 3DS) the shopper has already authorized the payment, and re-running + * the gateway widget's `onsubmit` here would start a *second* payment attempt. + * Worse, every widget reports failure from that second attempt — Adyen's + * `handleSubmit` always returns `false`, Stripe's `confirmPayment` rejects an + * intent that already succeeded — which left `isValid === false` and the order + * stranded at pending + authorized. Skip the widget and go straight to placing. + * + * NOTE: truthiness, not `!= null`. Integrators pass every one of these options + * as an empty string when the shopper is not returning from a redirect, so a + * null check here would skip the widget on the *normal* flow and nothing would + * ever be placed. + */ + const isReturningFromRedirect = Boolean( + options?.paypalPayerId || + options?.adyen?.MD || + options?.adyen?.redirectResult || + options?.checkoutCom?.session_id || + options?.stripe?.paymentIntentClientSecret + ) + if (currentPaymentMethodRef?.current?.onsubmit && !isReturningFromRedirect) { isValid = (await currentPaymentMethodRef.current?.onsubmit({ // @ts-expect-error no type paymentSource: checkPaymentSource, @@ -513,8 +542,10 @@ export function PlaceOrderButton(props: Props): JSX.Element { })) as boolean if ( !isValid && - // @ts-expect-error no type - checkPaymentSource?.payment_response?.resultCode === "Authorised" + isAdyenAuthorizedResultCode( + // @ts-expect-error no type + checkPaymentSource?.payment_response?.resultCode + ) ) { isValid = true } @@ -535,6 +566,21 @@ export function PlaceOrderButton(props: Props): JSX.Element { setPlaceOrder, onclickCallback: onClick, })) as boolean + } else if (isReturningFromRedirect) { + /** + * We skipped the widget's own validation above, so nothing has vetted this + * payment inside the component. Refuse only on an explicit negative signal + * from the gateway: methods that report nothing here (PayPal, Stripe — whose + * intent the redirect effect already verified — wire transfers) must stay + * placeable, and a refused redirect must not be placed just because we no + * longer re-submit it. + * + * Checkout.com is exempt: placing an order whose payment was declined is a + * deliberate feature there (unpaid orders), owned by the branch above. + */ + if (!options?.checkoutCom?.session_id && isRefusedPaymentResponse(checkPaymentSource)) { + isValid = false + } } else if (card?.brand && checkPaymentSourceStatus !== "declined") { isValid = true } @@ -568,6 +614,32 @@ export function PlaceOrderButton(props: Props): JSX.Element { setPlaceOrderStatus?.({ status: "standby" }) } } + /** + * Serialises place attempts, one per order. + * + * Both status checks in `placeOrderAttempt` are async, so two callers racing + * each other read the order as still `pending` and place it twice: the + * automatic redirect effect above, and the programmatic click the gateway + * widget fires once it has authorized the payment. A second place repeats + * every side effect of `setPlaceOrder`, `_save_billing_address_to_customer_ + * address_book` included, which leaves the shopper with the same address + * twice in their wallet. + */ + const handleClick = async (e?: MouseEvent): Promise => { + e?.preventDefault() + e?.stopPropagation() + if (order == null) return + if (placeInFlightRef.current === order.id) return + placeInFlightRef.current = order.id + try { + await placeOrderAttempt() + } finally { + // Reopened on purpose. An attempt that did not place must stay retryable + // by an explicit click; one that did is stopped by the already-placed + // checks in `placeOrderAttempt` and `setPlaceOrder` instead. + placeInFlightRef.current = null + } + } const disabledButton = disabled !== undefined ? disabled : notPermitted const labelButton = isLoading ? loadingLabel : typeof label === "function" ? label() : label const parentProps = { diff --git a/packages/react-components/src/components/payment_source/AdyenPayment.tsx b/packages/react-components/src/components/payment_source/AdyenPayment.tsx index b8c222fb..bb0f8357 100644 --- a/packages/react-components/src/components/payment_source/AdyenPayment.tsx +++ b/packages/react-components/src/components/payment_source/AdyenPayment.tsx @@ -28,6 +28,7 @@ import browserInfo, { cleanUrlBy } from "#utils/browserInfo" import { getPublicIP } from "#utils/getPublicIp" import { hasSubscriptions } from "#utils/hasSubscriptions" import { setCustomerOrderParam } from "#utils/localStorage" +import { isAdyenAuthorizedResultCode } from "#utils/paymentAuthorization" import type { PaymentSourceProps } from "./PaymentSource" interface PaymentMethodsStyle { @@ -298,7 +299,7 @@ export function AdyenPayment({ })) // @ts-expect-error no type const resultCode = pSource?.payment_response?.resultCode - if (["Authorised", "Pending", "Received"].includes(resultCode)) { + if (isAdyenAuthorizedResultCode(resultCode)) { // NOTE: unlike the `isValid` handlers above, clearing `disabled` here is // load-bearing — do not remove it for symmetry with them. Adyen has already // authorized the payment; all that is left is to place the order. Terms @@ -567,7 +568,7 @@ export function AdyenPayment({ // @ts-expect-error no type const issuerType = res?.payment_instrument?.issuer_type - if (["Authorised", "Pending", "Received"].includes(resultCode)) { + if (isAdyenAuthorizedResultCode(resultCode)) { if (["apple pay", "google pay"].includes(issuerType) && setPlaceOrder != null) { await setPlaceOrder({ paymentSource: res, diff --git a/packages/react-components/src/utils/paymentAuthorization.ts b/packages/react-components/src/utils/paymentAuthorization.ts new file mode 100644 index 00000000..f0504b86 --- /dev/null +++ b/packages/react-components/src/utils/paymentAuthorization.ts @@ -0,0 +1,39 @@ +/** + * Shared vocabulary for "is this payment authorized?". + * + * These lists used to be duplicated between `` and + * ``, and they drifted: the auto-place effect accepted + * `Pending`/`Received` while the fallback inside `handleClick` only accepted + * `Authorised`, so an async method (Klarna, iDEAL) came back authorized and the + * order was never placed. Keep the codes here so the two sides cannot disagree + * again. + */ + +/** + * Adyen result codes that mean the shopper is done and the money is committed — + * either authorized outright or accepted for an asynchronous authorization. + * @see https://docs.adyen.com/online-payments/payment-result-codes + */ +export const ADYEN_AUTHORIZED_RESULT_CODES = ["Authorised", "Pending", "Received"] + +/** Adyen result codes that mean the payment will not happen. */ +export const ADYEN_REFUSED_RESULT_CODES = ["Cancelled", "Refused", "Error"] + +export function isAdyenAuthorizedResultCode(resultCode?: string | null): boolean { + return resultCode != null && ADYEN_AUTHORIZED_RESULT_CODES.includes(resultCode) +} + +/** + * True when the gateway has explicitly told us the payment failed. Used as a + * veto, never as the permission to place: gateways that report nothing here + * (PayPal, Stripe, wire transfers) must stay placeable. + */ +export function isRefusedPaymentResponse(paymentSource?: unknown): boolean { + const paymentResponse = ( + paymentSource as { payment_response?: { resultCode?: string; status?: string } } | null + )?.payment_response + if (paymentResponse == null) return false + const resultCode = paymentResponse.resultCode + if (resultCode != null && ADYEN_REFUSED_RESULT_CODES.includes(resultCode)) return true + return paymentResponse.status?.toLowerCase?.() === "declined" +} diff --git a/packages/react-hooks-components/package.json b/packages/react-hooks-components/package.json index fbdc782d..8d999606 100644 --- a/packages/react-hooks-components/package.json +++ b/packages/react-hooks-components/package.json @@ -1,6 +1,6 @@ { "name": "@commercelayer/react-hooks-components", - "version": "5.0.0", + "version": "5.0.1-beta.0", "description": "Commerce Layer React Hooks", "type": "module", "main": "./dist/index.js",