From 24015a9254474055a6dca366d484980ec5e3aabb Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Tue, 8 Sep 2026 14:19:28 -0500 Subject: [PATCH 01/13] add stripe 3ds support --- .changeset/bright-cards-handle-3ds.md | 5 + .../checkout-buttons/credit-card/stripe.tsx | 8 +- .../payment/utils/use-confirm-checkout.ts | 15 ++ .../use-stripe-checkout.integration.test.tsx | 162 +++++++++++++++++- .../payment/utils/use-stripe-checkout.ts | 87 ++++++++-- .../react/src/lib/graphql-with-errors.test.ts | 71 ++++++++ packages/react/src/lib/graphql-with-errors.ts | 51 +++++- 7 files changed, 379 insertions(+), 20 deletions(-) create mode 100644 .changeset/bright-cards-handle-3ds.md create mode 100644 packages/react/src/lib/graphql-with-errors.test.ts diff --git a/.changeset/bright-cards-handle-3ds.md b/.changeset/bright-cards-handle-3ds.md new file mode 100644 index 00000000..bcd43c76 --- /dev/null +++ b/.changeset/bright-cards-handle-3ds.md @@ -0,0 +1,5 @@ +--- +'@godaddy/react': patch +--- + +Support Stripe 3DS next actions returned by checkout confirmation while preserving existing payment error behavior. diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx index 18269a77..c8607228 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx @@ -14,7 +14,9 @@ export function StripeCreditCardCheckoutButton() { const { isConfirmingCheckout } = useCheckoutContext(); const isPaymentDisabled = useIsPaymentDisabled(); const flushCheckoutSync = useFlushCheckoutSync(); - const { handleSubmit } = useStripeCheckout({ mode: 'card' }); + const { handleSubmit, isProcessingPayment } = useStripeCheckout({ + mode: 'card', + }); const handleStripeCheckout = async () => { const valid = await form.trigger(); @@ -36,7 +38,9 @@ export function StripeCreditCardCheckoutButton() { + ); + }, +})); + +function renderExpress(paymentStatus: string) { + const draftOrder = buildDraftOrder({ + statuses: { + status: paymentStatus === 'PAID' ? 'OPEN' : 'DRAFT', + paymentStatus, + }, + }); + const session = buildCheckoutSession({ + successUrl: 'https://merchant.example/success', + paymentMethods: { + card: null as never, + applePay: { processor: 'godaddy', checkoutTypes: ['express'] }, + }, + }); + mockGodaddyApi({ session, draftOrder }); + render( + + + + ); + return session; +} + +describe('Standalone express paid-order recovery', () => { + beforeEach(() => mockWindowLocation()); + + it.each(['load', 'confirmation failure'])( + 'redirects paid orders on %s', + async scenario => { + const session = renderExpress(scenario === 'load' ? 'PAID' : 'UNPAID'); + if (scenario === 'confirmation failure') { + const button = await screen.findByRole('button', { + name: 'Express pay', + }); + vi.mocked(confirmCheckout).mockImplementationOnce(async () => { + vi.mocked(getDraftOrder).mockResolvedValue({ + checkoutSession: { + ...session, + draftOrder: buildDraftOrder({ + statuses: { status: 'OPEN', paymentStatus: 'PAID' }, + }), + }, + }); + throw new Error('Confirmation response lost'); + }); + fireEvent.click(button); + } + expect(await screen.findByRole('status')).toHaveTextContent( + 'Payment successful' + ); + expect( + screen.queryByRole('button', { name: 'Express pay' }) + ).not.toBeInTheDocument(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1100); + }); + expect(window.location.href).toBe(session.successUrl); + expect(confirmCheckout).toHaveBeenCalledTimes( + scenario === 'load' ? 0 : 1 + ); + } + ); + + it('keeps express payment available for an unpaid order', async () => { + renderExpress('UNPAID'); + expect( + await screen.findByRole('button', { name: 'Express pay' }) + ).toBeInTheDocument(); + expect(window.location.href).not.toContain('/success'); + }); +}); diff --git a/packages/react/src/components/checkout/express-checkout/express-checkout.tsx b/packages/react/src/components/checkout/express-checkout/express-checkout.tsx index 52f4c483..dca8cb30 100644 --- a/packages/react/src/components/checkout/express-checkout/express-checkout.tsx +++ b/packages/react/src/components/checkout/express-checkout/express-checkout.tsx @@ -8,10 +8,12 @@ import { useCheckoutContext, } from '@/components/checkout/checkout'; import { CheckoutSection } from '@/components/checkout/checkout-section'; +import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; +import { usePaidOrderRedirect } from '@/components/checkout/order/use-paid-order-redirect'; import { PaymentMethodRenderer } from '@/components/checkout/payment/payment-method-renderer'; import { ConditionalExpressProviders } from '@/components/checkout/payment/utils/conditional-providers'; import { Target } from '@/components/checkout/target/target'; -import type { GoDaddyVariables } from '@/godaddy-provider'; +import { type GoDaddyVariables, useGoDaddyContext } from '@/godaddy-provider'; import { type Theme, useTheme } from '@/hooks/use-theme'; import { useVariables } from '@/hooks/use-variables'; import { TrackingProvider } from '@/tracking/tracking-provider'; @@ -84,6 +86,22 @@ function DraftOrderExpressCheckoutButtons() { ); } +function ExpressCheckoutContent() { + const { t } = useGoDaddyContext(); + const { data: order, isLoading } = useDraftOrder(); + const showPaidOrder = usePaidOrderRedirect(order); + + if (showPaidOrder) + return
{t.errors.paymentSuccessful}
; + if (isLoading) return null; + + return ( + + + + ); +} + export function DraftOrderExpressCheckout(props: ExpressCheckoutProps) { const { session, @@ -140,9 +158,7 @@ export function DraftOrderExpressCheckout(props: ExpressCheckoutProps) { > - - - + diff --git a/packages/react/src/components/checkout/form/checkout-form-container.tsx b/packages/react/src/components/checkout/form/checkout-form-container.tsx index 2653d18e..f1e282f0 100644 --- a/packages/react/src/components/checkout/form/checkout-form-container.tsx +++ b/packages/react/src/components/checkout/form/checkout-form-container.tsx @@ -1,7 +1,6 @@ -import { useEffect, useMemo } from 'react'; +import { useMemo } from 'react'; import { type CheckoutProps, - redirectToSuccessUrl, useCheckoutContext, } from '@/components/checkout/checkout'; import { CheckoutSkeleton } from '@/components/checkout/checkout-skeleton'; @@ -15,6 +14,7 @@ import { useDraftOrderProductsMap, useRefreshProductsWhenLineItemsChange, } from '@/components/checkout/order/use-draft-order-products'; +import { usePaidOrderRedirect } from '@/components/checkout/order/use-paid-order-redirect'; import { mapOrderToFormValues, mapSkusToItemsDisplay, @@ -40,13 +40,7 @@ export function CheckoutFormContainer({ const skusMap = useDraftOrderProductsMap(); const { data: order } = draftOrderQuery; - const isPaid = - order?.statuses?.paymentStatus?.trim().toUpperCase() === 'PAID'; - const showPaidOrder = isPaid && !isConfirmingCheckout; - - useEffect(() => { - if (showPaidOrder) redirectToSuccessUrl(session?.successUrl); - }, [showPaidOrder, session?.successUrl]); + const showPaidOrder = usePaidOrderRedirect(order); const { data: lineItems } = draftOrderLineItemsQuery; useRefreshProductsWhenLineItemsChange(lineItems); diff --git a/packages/react/src/components/checkout/order/use-paid-order-redirect.ts b/packages/react/src/components/checkout/order/use-paid-order-redirect.ts new file mode 100644 index 00000000..a0ce47c8 --- /dev/null +++ b/packages/react/src/components/checkout/order/use-paid-order-redirect.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; +import { + redirectToSuccessUrl, + useCheckoutContext, +} from '@/components/checkout/checkout'; +import type { DraftOrder } from '@/types'; + +export function usePaidOrderRedirect(order: DraftOrder | null | undefined) { + const { session, isConfirmingCheckout } = useCheckoutContext(); + const isPaid = + order?.statuses?.paymentStatus?.trim().toUpperCase() === 'PAID'; + const showPaidOrder = isPaid && !isConfirmingCheckout; + + useEffect(() => { + if (showPaidOrder) redirectToSuccessUrl(session?.successUrl); + }, [showPaidOrder, session?.successUrl]); + + return showPaidOrder; +} diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout-recovery.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout-recovery.ts new file mode 100644 index 00000000..bb928c15 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout-recovery.ts @@ -0,0 +1,42 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { useGoDaddyContext } from '@/godaddy-provider'; +import { getDraftOrder } from '@/lib/godaddy/godaddy'; +import { getPaymentActionRequiredResult } from '@/lib/graphql-with-errors'; + +// A failed confirmation response does not prove that payment failed. Refresh +// the authoritative order before callers unlock checkout and offer another try. +export function useConfirmCheckoutRecovery() { + const queryClient = useQueryClient(); + const { session, jwt } = useCheckoutContext(); + const { apiHost } = useGoDaddyContext(); + + return async function confirmWithRecovery( + confirm: () => Promise + ): Promise { + try { + return await confirm(); + } catch (error) { + if (session?.id && !getPaymentActionRequiredResult(error)) { + try { + const queryKey = checkoutQueryKeys.draftOrder(session.id); + // Discard reads started before confirmation; they may still say unpaid. + await queryClient.cancelQueries({ queryKey, exact: true }); + await queryClient.fetchQuery({ + queryKey, + queryFn: () => + jwt + ? getDraftOrder({ accessToken: jwt }, apiHost) + : getDraftOrder(session, apiHost), + staleTime: 0, + retry: false, + }); + } catch { + // Keep the original payment error if the status lookup also fails. + } + } + throw error; + } + }; +} diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts index 08a29de3..60890620 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts @@ -22,6 +22,7 @@ import { } from '@/tracking/track'; import type { ConfirmCheckoutMutationInput } from '@/types'; import { getStripeNextAction } from './stripe-next-action'; +import { useConfirmCheckoutRecovery } from './use-confirm-checkout-recovery'; export class CheckoutConfirmationBlockedError extends Error { constructor(message: string) { @@ -98,6 +99,7 @@ export function useConfirmCheckout() { const { data: order } = useDraftOrder(); const flushCheckoutSync = useFlushCheckoutSync(); const isPendingRef = useRef(false); + const confirmWithRecovery = useConfirmCheckoutRecovery(); return useMutation({ mutationFn: async ( @@ -194,27 +196,30 @@ export function useConfirmCheckout() { }, }); - const data = jwt - ? await confirmCheckout( - { - ...confirmCheckoutInput, - ...(isPickup ? pickUpData : {}), - }, - { accessToken: jwt, sessionId: session?.id || '' }, - apiHost - ) - : await confirmCheckout( - { - ...confirmCheckoutInput, - ...(isPickup ? pickUpData : {}), - }, - session, - apiHost - ); + const data = await confirmWithRecovery(async () => { + const result = jwt + ? await confirmCheckout( + { + ...confirmCheckoutInput, + ...(isPickup ? pickUpData : {}), + }, + { accessToken: jwt, sessionId: session?.id || '' }, + apiHost + ) + : await confirmCheckout( + { + ...confirmCheckoutInput, + ...(isPickup ? pickUpData : {}), + }, + session, + apiHost + ); - if (!data) { - throw new Error('Checkout confirmation failed'); - } + if (!result) { + throw new Error('Checkout confirmation failed'); + } + return result; + }); return data; } finally { diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.test.tsx b/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.test.tsx index cf5f4a64..42e083e5 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.test.tsx @@ -1,10 +1,12 @@ import { renderHook, waitFor } from '@testing-library/react'; import React from 'react'; -import { describe, expect, it } from 'vitest'; -import { checkoutContext } from '@/components/checkout/checkout'; +import { describe, expect, it, vi } from 'vitest'; +import { checkoutContext, useCheckoutContext } from '@/components/checkout/checkout'; import { PaymentProvider } from '@/components/checkout/payment/utils/use-confirm-checkout'; import { useConfirmExpressCheckout } from '@/components/checkout/payment/utils/use-confirm-express-checkout'; import { GoDaddyProvider } from '@/godaddy-provider'; +import { confirmCheckout, getDraftOrder } from '@/lib/godaddy/godaddy'; +import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { PaymentMethodType } from '@/types'; import { buildCheckoutSession, @@ -121,4 +123,68 @@ describe('useConfirmExpressCheckout', () => { expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); }); + it('keeps checkout locked while checking the order and preserves the original error if that check fails', async () => { + const session = buildCheckoutSession(); + mockGodaddyApi({ session, draftOrder: buildDraftOrder() }); + const error = new Error('Confirmation response lost'); + vi.mocked(confirmCheckout).mockRejectedValueOnce(error); + let rejectLookup!: (error: Error) => void; + vi.mocked(getDraftOrder).mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectLookup = reject; + }) + ); + const { result } = renderHook( + () => ({ + confirmation: useConfirmExpressCheckout(), + context: useCheckoutContext(), + }), + { wrapper: wrapper(session) } + ); + const outcome = result.current.confirmation + .mutateAsync({ + paymentToken: 'wallet-nonce', + paymentType: 'apple_pay', + paymentProvider: PaymentProvider.POYNT, + }) + .catch(caught => caught); + await waitFor(() => expect(getDraftOrder).toHaveBeenCalledTimes(1)); + expect(result.current.context.isConfirmingCheckout).toBe(true); + rejectLookup(new Error('Status lookup failed')); + expect(await outcome).toBe(error); + await waitFor(() => + expect(result.current.context.isConfirmingCheckout).toBe(false) + ); + expect(confirmCheckout).toHaveBeenCalledTimes(1); + }); + + it('does not refetch or replace a verification-required response', async () => { + const session = buildCheckoutSession(); + mockGodaddyApi({ session, draftOrder: buildDraftOrder() }); + const error = new GraphQLErrorWithCodes([ + { + code: 'PAYMENT_ACTION_REQUIRED', + extensions: { + paymentResult: { + status: 'ACTION_REQUIRED', + provider: 'STRIPE', + nextStep: { type: 'SDK_ACTION' }, + }, + }, + }, + ]); + vi.mocked(confirmCheckout).mockRejectedValueOnce(error); + const { result } = renderHook(() => useConfirmExpressCheckout(), { + wrapper: wrapper(session), + }); + await expect( + result.current.mutateAsync({ + paymentToken: 'wallet-nonce', + paymentType: 'apple_pay', + paymentProvider: PaymentProvider.STRIPE, + }) + ).rejects.toBe(error); + expect(getDraftOrder).not.toHaveBeenCalled(); + }); }); diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.ts index 1b7bd2a3..89051f1f 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.ts @@ -19,6 +19,7 @@ import { track, } from '@/tracking/track'; import type { ConfirmCheckoutMutationInput } from '@/types'; +import { useConfirmCheckoutRecovery } from './use-confirm-checkout-recovery'; export function useConfirmExpressCheckout() { const { @@ -31,6 +32,7 @@ export function useConfirmExpressCheckout() { const { apiHost } = useGoDaddyContext(); const isPaymentDisabled = useIsPaymentDisabled(); const isPendingRef = useRef(false); + const confirmWithRecovery = useConfirmCheckoutRecovery(); return useMutation({ mutationFn: async ( @@ -79,17 +81,20 @@ export function useConfirmExpressCheckout() { }, }); - const data = jwt - ? await confirmCheckout( - confirmCheckoutInput, - { accessToken: jwt, sessionId: session?.id || '' }, - apiHost - ) - : await confirmCheckout(confirmCheckoutInput, session, apiHost); + const data = await confirmWithRecovery(async () => { + const result = jwt + ? await confirmCheckout( + confirmCheckoutInput, + { accessToken: jwt, sessionId: session?.id || '' }, + apiHost + ) + : await confirmCheckout(confirmCheckoutInput, session, apiHost); - if (!data) { - throw new Error('Express checkout confirmation failed'); - } + if (!result) { + throw new Error('Express checkout confirmation failed'); + } + return result; + }); return data; } finally { From 1e48cd2091e30a30e3d64d82285649cfb1250493 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Tue, 22 Sep 2026 11:08:02 -0500 Subject: [PATCH 12/13] Handle Stripe verification for express checkout --- .changeset/bright-cards-handle-3ds.md | 2 + .../payment/utils/stripe-provider.tsx | 6 +- .../use-confirm-express-checkout.test.tsx | 146 +++++++- .../utils/use-confirm-express-checkout.ts | 37 +- .../use-stripe-checkout.integration.test.tsx | 322 +++++++++++++++++- .../payment/utils/use-stripe-checkout.ts | 256 +++++++------- 6 files changed, 631 insertions(+), 138 deletions(-) diff --git a/.changeset/bright-cards-handle-3ds.md b/.changeset/bright-cards-handle-3ds.md index 0164183e..4933bb3b 100644 --- a/.changeset/bright-cards-handle-3ds.md +++ b/.changeset/bright-cards-handle-3ds.md @@ -14,3 +14,5 @@ Report Stripe express completion with a generic event and Stripe's payment type When checkout loads an already-paid order, hide payment controls and redirect to the session's success URL. Show the existing payment-success message when no success URL is configured. Refresh the order after confirmation failures before allowing another attempt, and redirect paid orders in standalone express checkout as well as standard checkout. + +Handle Stripe verification for express wallets and resume the original PaymentIntent while preserving wallet checkout details. diff --git a/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx b/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx index ec1c816b..668fe563 100644 --- a/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx +++ b/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx @@ -9,7 +9,11 @@ import { import { useCheckoutContext } from '@/components/checkout/checkout'; import { useStripePaymentIntent } from '@/components/checkout/payment/utils/use-stripe-payment-intent'; -type PendingStripeIntent = { sessionId: string | undefined; id: string }; +type PendingStripeIntent = { + sessionId: string | undefined; + id: string; + paymentType?: string; +}; const StripePaymentContext = createContext | null>(null); diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.test.tsx b/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.test.tsx index 782625d0..b9a9be3f 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.test.tsx @@ -1,4 +1,6 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import type { StripeExpressCheckoutElementConfirmEvent } from '@stripe/stripe-js'; +import { useQueryClient } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { describe, expect, it, vi } from 'vitest'; @@ -12,6 +14,29 @@ import { GoDaddyProvider } from '@/godaddy-provider'; import { confirmCheckout, getDraftOrder } from '@/lib/godaddy/godaddy'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { PaymentMethodType } from '@/types'; +import { StripeProvider } from './stripe-provider'; +import { useStripeCheckout } from './use-stripe-checkout'; + +const stripe = vi.hoisted(() => ({ + createPaymentMethod: vi.fn(), + handleNextAction: vi.fn(), +})); +vi.mock('@stripe/react-stripe-js', () => ({ + CardElement: () => null, + useStripe: () => stripe, + useElements: () => ({}), +})); +vi.mock('./use-build-payment-request', () => ({ + useBuildPaymentRequest: () => ({}), +})); +vi.mock('./use-flush-checkout-sync', () => ({ + useFlushCheckoutSync: () => vi.fn(), +})); +vi.mock('./use-confirm-checkout', async importOriginal => ({ + ...(await importOriginal()), + useConfirmCheckout: () => ({ mutateAsync: vi.fn() }), +})); + import { buildCheckoutSession, buildDraftOrder, @@ -49,7 +74,9 @@ function wrapper( setCheckoutErrors, }} > - {formValues ? {children} : children} + + {formValues ? {children} : children} + ); @@ -231,3 +258,118 @@ describe('useConfirmExpressCheckout', () => { expect(getDraftOrder).not.toHaveBeenCalled(); }); }); + +it('keeps express checkout locked during 3DS and allows only the matching intent to resume', async () => { + const session = buildCheckoutSession(); + mockGodaddyApi({ session, draftOrder: buildDraftOrder() }); + vi.mocked(confirmCheckout).mockRejectedValueOnce( + new GraphQLErrorWithCodes([ + { + code: 'PAYMENT_ACTION_REQUIRED', + extensions: { + paymentResult: { + status: 'ACTION_REQUIRED', + provider: 'STRIPE', + paymentReference: 'pi_wallet', + nextStep: { + type: 'SDK_ACTION', + sdk: 'STRIPE_JS', + action: 'HANDLE_NEXT_ACTION', + clientSecret: 'pi_wallet_secret', + }, + }, + }, + }, + ]) + ); + stripe.createPaymentMethod.mockResolvedValue({ + paymentMethod: { id: 'pm_wallet' }, + }); + let finishChallenge!: (value: unknown) => void; + stripe.handleNextAction.mockReturnValueOnce( + new Promise(resolve => { + finishChallenge = resolve; + }) + ); + const { result } = renderHook( + () => ({ + payment: useStripeCheckout({ mode: 'express' }), + otherConfirmation: useConfirmExpressCheckout(), + context: useCheckoutContext(), + }), + { wrapper: wrapper(session) } + ); + let submission!: ReturnType; + await act(async () => { + submission = result.current.payment.handleSubmit({ + event: { + expressPaymentType: 'google_pay', + } as StripeExpressCheckoutElementConfirmEvent, + }); + }); + await waitFor(() => expect(stripe.handleNextAction).toHaveBeenCalledTimes(1)); + expect(result.current.context.isConfirmingCheckout).toBe(true); + expect(result.current.context.checkoutErrors).toBeUndefined(); + expect(getDraftOrder).not.toHaveBeenCalled(); + expect(confirmCheckout).toHaveBeenCalledTimes(1); + await act(async () => { + await expect( + result.current.otherConfirmation.mutateAsync({ + paymentToken: 'pm_other', + paymentType: 'google_pay', + paymentProvider: PaymentProvider.STRIPE, + }) + ).rejects.toThrow('Checkout confirmation is already in progress'); + }); + expect(result.current.context.isConfirmingCheckout).toBe(true); + await act(async () => { + finishChallenge({ + paymentIntent: { id: 'pi_wallet', status: 'succeeded' }, + }); + await submission; + }); + expect(confirmCheckout).toHaveBeenCalledTimes(2); + expect( + vi.mocked(confirmCheckout).mock.calls.map(([input]) => input.paymentToken) + ).toEqual(['pm_wallet', 'pi_wallet']); + expect(stripe.createPaymentMethod).toHaveBeenCalledTimes(1); +}); + +it.each(['query', 'mutation'])( + 'blocks a fresh express payment during another %s', + async kind => { + const session = buildCheckoutSession(); + mockGodaddyApi({ session, draftOrder: buildDraftOrder() }); + const { result } = renderHook( + () => ({ + confirmation: useConfirmExpressCheckout(), + client: useQueryClient(), + }), + { wrapper: wrapper(session) } + ); + let finish!: () => void; + const pending = new Promise(resolve => { + finish = resolve; + }); + const work = + kind === 'query' + ? result.current.client.fetchQuery({ + queryKey: ['other-work'], + queryFn: () => pending.then(() => null), + }) + : result.current.client + .getMutationCache() + .build(result.current.client, { mutationFn: () => pending }) + .execute(undefined); + await expect( + result.current.confirmation.mutateAsync({ + paymentToken: 'pm_wallet', + paymentType: 'google_pay', + paymentProvider: PaymentProvider.STRIPE, + }) + ).rejects.toThrow('Checkout is currently busy'); + expect(confirmCheckout).not.toHaveBeenCalled(); + finish(); + await work; + } +); diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.ts index 89051f1f..2d0176de 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.ts @@ -1,4 +1,4 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useRef } from 'react'; import { redirectToSuccessUrl, @@ -9,7 +9,6 @@ import { isCheckoutConfirmationBlockedError, PaymentProvider, } from '@/components/checkout/payment/utils/use-confirm-checkout'; -import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; import { useGoDaddyContext } from '@/godaddy-provider'; import { confirmCheckout } from '@/lib/godaddy/godaddy'; import { eventIds } from '@/tracking/events'; @@ -19,6 +18,7 @@ import { track, } from '@/tracking/track'; import type { ConfirmCheckoutMutationInput } from '@/types'; +import { getStripeNextAction } from './stripe-next-action'; import { useConfirmCheckoutRecovery } from './use-confirm-checkout-recovery'; export function useConfirmExpressCheckout() { @@ -30,8 +30,12 @@ export function useConfirmExpressCheckout() { setCheckoutErrors, } = useCheckoutContext(); const { apiHost } = useGoDaddyContext(); - const isPaymentDisabled = useIsPaymentDisabled(); + const queryClient = useQueryClient(); const isPendingRef = useRef(false); + const pendingActionRef = useRef<{ + sessionId: string; + paymentReference: string; + } | null>(null); const confirmWithRecovery = useConfirmCheckoutRecovery(); return useMutation({ @@ -47,12 +51,20 @@ export function useConfirmExpressCheckout() { if (!input?.paymentType) { throw new Error('Express checkout payment type is unavailable'); } - if (isConfirmingCheckout) { + const isContinuation = + input.paymentProvider === PaymentProvider.STRIPE && + pendingActionRef.current?.sessionId === session.id && + pendingActionRef.current.paymentReference === input.paymentToken; + if (isConfirmingCheckout && !isContinuation) { throw new CheckoutConfirmationBlockedError( 'Checkout confirmation is already in progress' ); } - if (isPaymentDisabled) { + // This mutation already contributes one to the live pending count. + // A render-time busy flag can count confirmation itself and block resumption. + const isOtherWorkPending = + queryClient.isMutating() > 1 || queryClient.isFetching() > 0; + if (isOtherWorkPending && !isContinuation) { throw new CheckoutConfirmationBlockedError( 'Checkout is currently busy' ); @@ -103,6 +115,7 @@ export function useConfirmExpressCheckout() { }, onSuccess: (data, input) => { if (!data) return; + pendingActionRef.current = null; let completedEventId: TrackingEventId | null = null; switch (input.paymentType) { case 'apple_pay': @@ -148,6 +161,20 @@ export function useConfirmExpressCheckout() { onError: (error: unknown, data) => { if (isCheckoutConfirmationBlockedError(error)) return; + const nextAction = getStripeNextAction(error); + if ( + data?.paymentProvider === PaymentProvider.STRIPE && + nextAction && + session?.id + ) { + pendingActionRef.current = { + sessionId: session.id, + paymentReference: nextAction.paymentReference, + }; + return; + } + pendingActionRef.current = null; + track({ eventId: eventIds.checkoutError, type: TrackingEventType.EVENT, diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx index edb18b05..0febfce7 100644 --- a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx @@ -1,5 +1,5 @@ import type { StripeExpressCheckoutElementConfirmEvent } from '@stripe/stripe-js'; -import { act, renderHook } from '@testing-library/react'; +import { act, render, renderHook } from '@testing-library/react'; import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { checkoutContext } from '@/components/checkout/checkout'; @@ -624,21 +624,333 @@ describe('useStripeCheckout payment request resolution', () => { ); } ); +}); - it('shows a localizable action-required error and unlocks express checkout', async () => { - const error = actionRequiredError(); +describe('Stripe express verification', () => { + const expressData = { + event: { + expressPaymentType: 'google_pay', + billingDetails: { name: 'Wallet Buyer', email: 'buyer@example.com' }, + shippingAddress: { + name: 'Wallet Buyer', + address: { + line1: '123 Main St', + city: 'Austin', + state: 'TX', + postal_code: '78701', + country: 'US', + }, + }, + } as StripeExpressCheckoutElementConfirmEvent, + shippingTotal: { currencyCode: 'USD', value: 500 }, + selectedShippingMethod: { + displayName: 'Standard', + carrierCode: 'carrier', + serviceCode: 'standard', + cost: null, + description: null, + features: null, + maxDeliveryDate: null, + minDeliveryDate: null, + }, + }; + + beforeEach(() => { + vi.resetAllMocks(); + mocks.sessionId = 'session-1'; + mocks.createPaymentMethod.mockResolvedValue({ + paymentMethod: { + id: 'pm_wallet', + card: { wallet: { type: 'google_pay' } }, + }, + }); + mocks.handleNextAction.mockResolvedValue({ + paymentIntent: { id: 'pi-confirmed', status: 'succeeded' }, + }); + mocks.confirmExpress.mockResolvedValue(undefined); + }); + + it.each([ + 'succeeded', + 'processing', + 'requires_capture', + 'requires_confirmation', + ])( + 'resumes the express intent after %s and preserves the wallet payload', + async status => { + mocks.confirmExpress.mockRejectedValueOnce(actionRequiredError()); + mocks.handleNextAction.mockResolvedValueOnce({ + paymentIntent: { id: 'pi-confirmed', status }, + }); + const { result } = renderHook( + () => useStripeCheckout({ mode: 'express' }), + { wrapper: Wrapper } + ); + await act(async () => { + await result.current.handleSubmit(expressData); + }); + expect(mocks.handleNextAction).toHaveBeenCalledWith({ + clientSecret: 'pi-secret', + }); + expect(mocks.confirmExpress).toHaveBeenCalledTimes(2); + const initialInput = mocks.confirmExpress.mock.calls[0][0]; + expect(initialInput).toMatchObject({ + paymentToken: 'pm_wallet', + paymentType: 'google_pay', + paymentProvider: 'STRIPE', + isExpress: true, + billing: { email: 'buyer@example.com' }, + shipping: { address: { addressLine1: '123 Main St' } }, + shippingTotal: { currencyCode: 'USD', value: 500 }, + shippingLines: [{ name: 'Standard' }], + }); + expect(mocks.confirmExpress.mock.calls[1][0]).toEqual({ + ...initialInput, + paymentToken: 'pi-confirmed', + }); + expect(mocks.createPaymentMethod).toHaveBeenCalledTimes(1); + expect(mocks.setCheckoutErrors).not.toHaveBeenCalled(); + expect(mocks.track).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: eventIds.expressCheckoutCompleted, + properties: { paymentType: 'google_pay', provider: 'stripe' }, + }) + ); + } + ); + + it('ignores secondary submissions throughout the challenge and final confirmation', async () => { + mocks.confirmExpress.mockRejectedValueOnce(actionRequiredError()); + let completeChallenge!: (value: unknown) => void; + mocks.handleNextAction.mockReturnValueOnce( + new Promise(resolve => { + completeChallenge = resolve; + }) + ); + let completeConfirmation!: () => void; + mocks.confirmExpress.mockReturnValueOnce( + new Promise(resolve => { + completeConfirmation = resolve; + }) + ); + const { result } = renderHook( + () => useStripeCheckout({ mode: 'express' }), + { wrapper: Wrapper } + ); + let submission!: ReturnType; + await act(async () => { + submission = result.current.handleSubmit(expressData); + }); + await act(async () => { + await result.current.handleSubmit(expressData); + }); + expect(mocks.confirmExpress).toHaveBeenCalledTimes(1); + expect(result.current.isProcessingPayment).toBe(true); + await act(async () => { + completeChallenge({ + paymentIntent: { id: 'pi-confirmed', status: 'succeeded' }, + }); + }); + await act(async () => { + await result.current.handleSubmit(expressData); + }); + expect(mocks.confirmExpress).toHaveBeenCalledTimes(2); + expect(mocks.createPaymentMethod).toHaveBeenCalledTimes(1); + expect(mocks.track).not.toHaveBeenCalledWith( + expect.objectContaining({ eventId: eventIds.expressCheckoutCompleted }) + ); + await act(async () => { + completeConfirmation(); + await submission; + }); + expect(result.current.isProcessingPayment).toBe(false); + }); + + it.each(['canceled', 'requires_payment_method'])( + 'reports authentication failure and permits a new wallet attempt after %s', + async status => { + mocks.confirmExpress.mockRejectedValueOnce(actionRequiredError()); + mocks.handleNextAction.mockResolvedValueOnce({ + error: { + code: 'payment_intent_authentication_failure', + payment_intent: { id: 'pi-confirmed', status }, + }, + }); + const { result } = renderHook( + () => useStripeCheckout({ mode: 'express' }), + { wrapper: Wrapper } + ); + await act(async () => { + await expect( + result.current.handleSubmit(expressData) + ).rejects.toBeInstanceOf(GraphQLErrorWithCodes); + }); + expect(mocks.confirmExpress).toHaveBeenCalledTimes(1); + expect(mocks.setCheckoutErrors).toHaveBeenCalledWith([ + 'AUTHORIZATION_FAILED', + ]); + expect(mocks.setIsConfirmingCheckout).toHaveBeenCalledWith(false); + expect(mocks.track).not.toHaveBeenCalledWith( + expect.objectContaining({ eventId: eventIds.expressCheckoutCompleted }) + ); + await act(async () => { + await result.current.handleSubmit(expressData); + }); + expect(mocks.createPaymentMethod).toHaveBeenCalledTimes(2); + } + ); + + it.each(['SDK transport', 'final confirmation'])( + 'reuses the intent after an uncertain %s failure and hook remount', + async stage => { + const failure = new Error('Connection lost'); + mocks.confirmExpress.mockRejectedValueOnce(actionRequiredError()); + if (stage === 'SDK transport') + mocks.handleNextAction.mockRejectedValueOnce(failure); + else mocks.confirmExpress.mockRejectedValueOnce(failure); + let current!: ReturnType; + function Payment() { + current = useStripeCheckout({ mode: 'express' }); + return null; + } + const { rerender } = render( + + + + ); + await act(async () => { + await expect(current.handleSubmit(expressData)).rejects.toBe(failure); + }); + rerender({null}); + rerender( + + + + ); + await act(async () => { + await current.handleSubmit(expressData); + }); + expect(mocks.createPaymentMethod).toHaveBeenCalledTimes(1); + expect(mocks.confirmExpress).toHaveBeenLastCalledWith( + expect.objectContaining({ + paymentToken: 'pi-confirmed', + paymentType: 'google_pay', + }) + ); + } + ); + + it.each([ + [ + 'legacy failure', + new GraphQLErrorWithCodes([{ code: 'TRANSACTION_PROCESSING_FAILED' }]), + ], + [ + 'unsupported next action', + new GraphQLErrorWithCodes([ + { + code: 'PAYMENT_ACTION_REQUIRED', + extensions: { + paymentResult: { + status: 'ACTION_REQUIRED', + provider: 'STRIPE', + paymentReference: 'pi-confirmed', + nextStep: { type: 'REDIRECT', url: 'https://example.com/verify' }, + }, + }, + }, + ]), + ], + ])('fails safely for %s without invoking the SDK', async (_, error) => { mocks.confirmExpress.mockRejectedValueOnce(error); const { result } = renderHook( () => useStripeCheckout({ mode: 'express' }), { wrapper: Wrapper } ); await act(async () => { - await expect(result.current.handleSubmit()).rejects.toBe(error); + await expect(result.current.handleSubmit(expressData)).rejects.toBe( + error + ); }); expect(mocks.handleNextAction).not.toHaveBeenCalled(); expect(mocks.setCheckoutErrors).toHaveBeenCalledWith([ - 'PAYMENT_ACTION_REQUIRED', + 'TRANSACTION_PROCESSING_FAILED', ]); expect(mocks.setIsConfirmingCheckout).toHaveBeenCalledWith(false); }); + + it('does not loop when final confirmation still requires action', async () => { + mocks.confirmExpress + .mockRejectedValueOnce(actionRequiredError()) + .mockRejectedValueOnce(actionRequiredError()); + const { result } = renderHook( + () => useStripeCheckout({ mode: 'express' }), + { wrapper: Wrapper } + ); + await act(async () => { + await expect( + result.current.handleSubmit(expressData) + ).rejects.toBeInstanceOf(GraphQLErrorWithCodes); + }); + expect(mocks.handleNextAction).toHaveBeenCalledTimes(1); + expect(mocks.confirmExpress).toHaveBeenCalledTimes(2); + expect(mocks.setCheckoutErrors).toHaveBeenCalledWith([ + 'TRANSACTION_PROCESSING_FAILED', + ]); + await act(async () => { + await result.current.handleSubmit(expressData); + }); + expect(mocks.createPaymentMethod).toHaveBeenCalledTimes(1); + expect(mocks.confirmExpress).toHaveBeenLastCalledWith( + expect.objectContaining({ paymentToken: 'pi-confirmed' }) + ); + }); + + it('does not submit an SDK result belonging to another intent', async () => { + mocks.confirmExpress.mockRejectedValueOnce(actionRequiredError()); + mocks.handleNextAction.mockResolvedValueOnce({ + paymentIntent: { id: 'pi_other', status: 'succeeded' }, + }); + const { result } = renderHook( + () => useStripeCheckout({ mode: 'express' }), + { wrapper: Wrapper } + ); + await act(async () => { + await expect( + result.current.handleSubmit(expressData) + ).rejects.toBeInstanceOf(GraphQLErrorWithCodes); + }); + expect(mocks.confirmExpress).toHaveBeenCalledTimes(1); + await act(async () => { + await result.current.handleSubmit(expressData); + }); + expect(mocks.confirmExpress).toHaveBeenLastCalledWith( + expect.objectContaining({ paymentToken: 'pi-confirmed' }) + ); + expect(mocks.createPaymentMethod).toHaveBeenCalledTimes(1); + }); + + it('discards the pending express intent when the checkout session changes', async () => { + mocks.confirmExpress + .mockRejectedValueOnce(actionRequiredError()) + .mockRejectedValueOnce(new Error('Connection lost')); + const { result, rerender } = renderHook( + () => useStripeCheckout({ mode: 'express' }), + { wrapper: Wrapper } + ); + await act(async () => { + await expect(result.current.handleSubmit(expressData)).rejects.toThrow( + 'Connection lost' + ); + }); + mocks.sessionId = 'session-2'; + rerender(); + await act(async () => { + await result.current.handleSubmit(expressData); + }); + expect(mocks.createPaymentMethod).toHaveBeenCalledTimes(2); + expect(mocks.confirmExpress).toHaveBeenLastCalledWith( + expect.objectContaining({ paymentToken: 'pm_wallet' }) + ); + }); }); diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts index 5bf233f6..c5d32caa 100644 --- a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts @@ -32,6 +32,18 @@ import { } from './stripe-next-action'; import { usePendingStripeIntent } from './stripe-provider'; +function confirmationErrorCodes(error: unknown): string[] { + if ( + error instanceof GraphQLErrorWithCodes && + error.codes.length && + !getPaymentActionRequiredResult(error) && + !error.codes.includes('PAYMENT_ACTION_REQUIRED') + ) { + return error.codes; + } + return ['TRANSACTION_PROCESSING_FAILED']; +} + type UseStripeCheckoutOptions = { mode: 'card' | 'express'; clientSecret?: string | null; @@ -102,10 +114,86 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { return; } - if (mode === 'card') { - if (pendingIntent.current?.sessionId !== session?.id) { + if (pendingIntent.current?.sessionId !== session?.id) { + pendingIntent.current = null; + } + + // Both card and express payments resume the same intent after authentication. + const confirmWithNextAction = async ( + paymentToken: string, + paymentType: string, + confirm: (token: string) => Promise + ) => { + try { + await confirm(paymentToken); pendingIntent.current = null; + } catch (error) { + if (isCheckoutConfirmationBlockedError(error)) throw error; + const nextAction = getStripeNextAction(error); + if (!nextAction) throw error; + + // Keep the reference even if the SDK loses its response after payment completes. + pendingIntent.current = { + sessionId: session?.id, + id: nextAction.paymentReference, + paymentType, + }; + track({ + eventId: eventIds.paymentChallengeStarted, + type: TrackingEventType.EVENT, + properties: { provider: 'STRIPE' }, + }); + let challengeSucceeded = false; + try { + const actionResult = await stripe.handleNextAction({ + clientSecret: nextAction.clientSecret, + }); + if ( + actionResult.error || + actionResult.paymentIntent?.id !== + nextAction.paymentReference || + ![ + 'succeeded', + 'processing', + 'requires_capture', + 'requires_confirmation', + ].includes(actionResult.paymentIntent.status) + ) { + const intent = + actionResult.paymentIntent ?? + actionResult.error?.payment_intent; + // Only a definite unpaid outcome permits a replacement payment. + if ( + intent?.id === nextAction.paymentReference && + ['requires_payment_method', 'canceled'].includes( + intent.status + ) + ) { + pendingIntent.current = null; + } + throw new GraphQLErrorWithCodes([ + { + code: actionResult.error + ? stripeCheckoutErrorCode(actionResult.error.code) + : 'AUTHORIZATION_FAILED', + }, + ]); + } + + challengeSucceeded = true; + await confirm(actionResult.paymentIntent.id); + pendingIntent.current = null; + } finally { + track({ + eventId: eventIds.paymentChallengeCompleted, + type: TrackingEventType.EVENT, + properties: { provider: 'STRIPE', success: challengeSucceeded }, + }); + } } + }; + + if (mode === 'card') { let paymentToken = pendingIntent.current?.id; if (!paymentToken) { const cardElement = elements.getElement(CardElement); @@ -145,105 +233,19 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { }; try { - await confirmCheckout.mutateAsync(confirmInput); - pendingIntent.current = null; - } catch (err: unknown) { - if (isCheckoutConfirmationBlockedError(err)) return; - const nextAction = getStripeNextAction(err); - if (!nextAction) { - const errorCodes = - err instanceof GraphQLErrorWithCodes ? err.codes : []; - setCheckoutErrors( - errorCodes.length > 0 && - !errorCodes.includes('PAYMENT_ACTION_REQUIRED') - ? errorCodes - : ['TRANSACTION_PROCESSING_FAILED'] - ); - setIsConfirmingCheckout(false); - return; - } - - // Keep the reference even if the SDK loses its response after payment completes. - pendingIntent.current = { - sessionId: session?.id, - id: nextAction.paymentReference, - }; - track({ - eventId: eventIds.paymentChallengeStarted, - type: TrackingEventType.EVENT, - properties: { provider: 'STRIPE' }, - }); - let challengeSucceeded = false; - try { - const actionResult = await stripe.handleNextAction({ - clientSecret: nextAction.clientSecret, - }); - - if ( - actionResult.error || - !actionResult.paymentIntent?.id || - ![ - 'succeeded', - 'processing', - 'requires_capture', - 'requires_confirmation', - ].includes(actionResult.paymentIntent.status) - ) { - const intent = - actionResult.paymentIntent ?? - actionResult.error?.payment_intent; - // Only a definite unpaid outcome permits a replacement payment. - // Connection errors without an intent status must keep the reference. - if ( - intent?.id === nextAction.paymentReference && - ['requires_payment_method', 'canceled'].includes( - intent.status - ) - ) { - pendingIntent.current = null; - } - setCheckoutErrors([ - actionResult.error - ? stripeCheckoutErrorCode(actionResult.error.code) - : 'AUTHORIZATION_FAILED', - ]); - setIsConfirmingCheckout(false); - return; - } - - challengeSucceeded = true; - pendingIntent.current = { - sessionId: session?.id, - id: actionResult.paymentIntent.id, - }; - await confirmCheckout.mutateAsync({ - ...confirmInput, - paymentToken: actionResult.paymentIntent.id, - }); - pendingIntent.current = null; - } catch (finalizationError: unknown) { - if (isCheckoutConfirmationBlockedError(finalizationError)) - return; - const isRepeatedActionRequired = Boolean( - getPaymentActionRequiredResult(finalizationError) - ); - setCheckoutErrors( - finalizationError instanceof GraphQLErrorWithCodes && - !isRepeatedActionRequired - ? finalizationError.codes - : ['TRANSACTION_PROCESSING_FAILED'] - ); - setIsConfirmingCheckout(false); - } finally { - track({ - eventId: eventIds.paymentChallengeCompleted, - type: TrackingEventType.EVENT, - properties: { - provider: 'STRIPE', - success: challengeSucceeded, - }, - }); - } + await confirmWithNextAction( + paymentToken, + confirmInput.paymentType, + token => + confirmCheckout.mutateAsync({ + ...confirmInput, + paymentToken: token, + }) + ); + } catch (error) { + if (isCheckoutConfirmationBlockedError(error)) return; + setCheckoutErrors(confirmationErrorCodes(error)); + setIsConfirmingCheckout(false); } } else { setCheckoutErrors(['TRANSACTION_PROCESSING_FAILED']); @@ -251,28 +253,30 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { } if (mode === 'express') { - const { error, paymentMethod } = await stripe.createPaymentMethod({ - elements, - params: buildStripeExpressPaymentMethodParams( - expressData?.event.billingDetails - ), - }); - - if (error) { - setCheckoutErrors([stripeCheckoutErrorCode(error.code)]); - return; + let paymentToken = pendingIntent.current?.id; + let paymentType = pendingIntent.current?.paymentType; + if (!paymentToken) { + const { error, paymentMethod } = await stripe.createPaymentMethod({ + elements, + params: buildStripeExpressPaymentMethodParams( + expressData?.event.billingDetails + ), + }); + if (error || !paymentMethod) { + const code = stripeCheckoutErrorCode(error?.code); + setCheckoutErrors([code]); + throw new GraphQLErrorWithCodes([{ code }]); + } + paymentToken = paymentMethod.id; + paymentType = paymentMethod.card?.wallet?.type; } - if (paymentMethod) { + if (paymentToken) { try { - // Build the checkout body similar to godaddy.tsx const event = expressData?.event; const currencyCode = expressData?.shippingTotal?.currencyCode || 'USD'; - - const walletType = paymentMethod.card?.wallet?.type; - const paymentType = - walletType || event?.expressPaymentType || 'card'; + paymentType = paymentType || event?.expressPaymentType || 'card'; // Map Stripe billing details to checkout format const billing = event?.billingDetails @@ -349,8 +353,8 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { ] : undefined; - await confirmExpressCheckout.mutateAsync({ - paymentToken: paymentMethod.id, + const confirmInput = { + paymentToken, paymentType, paymentProvider: PaymentProvider.STRIPE, isExpress: true, @@ -372,7 +376,13 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { ...(shipping ? { shipping } : {}), // Include shipping lines if available ...(shippingLines ? { shippingLines } : {}), - }); + }; + await confirmWithNextAction(paymentToken, paymentType, token => + confirmExpressCheckout.mutateAsync({ + ...confirmInput, + paymentToken: token, + }) + ); if (event) { track({ eventId: eventIds.expressCheckoutCompleted, @@ -385,11 +395,7 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { } } catch (err: unknown) { if (isCheckoutConfirmationBlockedError(err)) throw err; - setCheckoutErrors( - err instanceof GraphQLErrorWithCodes && err.codes.length - ? err.codes - : ['TRANSACTION_PROCESSING_FAILED'] - ); + setCheckoutErrors(confirmationErrorCodes(err)); setIsConfirmingCheckout(false); throw err; // Re-throw so caller can handle } From ff4c9ac235bfe588296603c2b2952355757312bd Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Tue, 22 Sep 2026 12:23:30 -0500 Subject: [PATCH 13/13] adjust PI handling for stripe --- .changeset/bright-cards-handle-3ds.md | 14 +- .../use-stripe-checkout.integration.test.tsx | 151 ++++++++++++++++++ .../payment/utils/use-stripe-checkout.ts | 24 ++- .../react/src/lib/godaddy/checkout-env.ts | 22 ++- .../react/src/lib/graphql-with-errors.test.ts | 30 ++++ 5 files changed, 226 insertions(+), 15 deletions(-) diff --git a/.changeset/bright-cards-handle-3ds.md b/.changeset/bright-cards-handle-3ds.md index 4933bb3b..628b005c 100644 --- a/.changeset/bright-cards-handle-3ds.md +++ b/.changeset/bright-cards-handle-3ds.md @@ -3,16 +3,6 @@ '@godaddy/localizations': patch --- -Support Stripe 3DS next actions returned by checkout confirmation while preserving existing payment error behavior. +Add Stripe 3DS verification support for card and express checkout, with localized payment errors and compatibility with existing checkout error handling. -Guard Stripe submissions during validation, synchronization, and authentication so a duplicate attempt cannot unlock an active payment. - -Silently ignore duplicate Stripe submissions and record express payment success only after confirmation completes. - -Report Stripe express completion with a generic event and Stripe's payment type instead of labeling every wallet as Apple Pay. - -When checkout loads an already-paid order, hide payment controls and redirect to the session's success URL. Show the existing payment-success message when no success URL is configured. - -Refresh the order after confirmation failures before allowing another attempt, and redirect paid orders in standalone express checkout as well as standard checkout. - -Handle Stripe verification for express wallets and resume the original PaymentIntent while preserving wallet checkout details. +Improve payment retry handling, prevent duplicate submissions, and recognize already-paid orders. Update express payment tracking to reflect the payment method used and successful checkout completion. diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx index 0febfce7..b9f4b73f 100644 --- a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx @@ -115,6 +115,157 @@ function actionRequiredError() { ]); } +describe.each(['card', 'express'] as const)( + '%s transaction status recovery', + mode => { + const confirm = mode === 'card' ? mocks.confirm : mocks.confirmExpress; + + beforeEach(() => { + vi.resetAllMocks(); + mocks.sessionId = 'session-1'; + mocks.flush.mockResolvedValue({ latestOrder: mocks.latestOrder }); + mocks.buildFromOrder.mockReturnValue({ stripePaymentMethodParams: {} }); + mocks.createPaymentMethod + .mockResolvedValueOnce({ paymentMethod: { id: 'pm_original' } }) + .mockResolvedValue({ paymentMethod: { id: 'pm_replacement' } }); + mocks.handleNextAction.mockResolvedValue({ + paymentIntent: { id: 'pi-confirmed', status: 'requires_confirmation' }, + }); + confirm.mockResolvedValue(undefined); + }); + + async function submitFailure( + submit: () => Promise, + error: unknown + ) { + await act(async () => { + if (mode === 'express') await expect(submit()).rejects.toBe(error); + else await submit(); + }); + } + + it.each(['final confirmation', 'later retry'])( + 'allows a replacement payment after FAILED during %s', + async stage => { + const failure = new GraphQLErrorWithCodes([ + { + code: 'TRANSACTION_PROCESSING_FAILED', + extensions: { transactionStatus: 'FAILED' }, + }, + ]); + const networkError = new Error('Connection lost'); + confirm.mockRejectedValueOnce(actionRequiredError()); + if (stage === 'later retry') + confirm.mockRejectedValueOnce(networkError); + confirm.mockRejectedValueOnce(failure); + const { result } = renderHook(() => useStripeCheckout({ mode }), { + wrapper: Wrapper, + }); + + if (stage === 'later retry') { + await submitFailure( + () => result.current.handleSubmit(), + networkError + ); + } + await submitFailure(() => result.current.handleSubmit(), failure); + expect(mocks.setCheckoutErrors).toHaveBeenLastCalledWith([ + 'TRANSACTION_PROCESSING_FAILED', + ]); + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(confirm.mock.calls.map(([input]) => input.paymentToken)).toEqual( + [ + 'pm_original', + 'pi-confirmed', + ...(stage === 'later retry' ? ['pi-confirmed'] : []), + 'pm_replacement', + ] + ); + expect(mocks.createPaymentMethod).toHaveBeenCalledTimes(2); + expect(mocks.handleNextAction).toHaveBeenCalledTimes(1); + } + ); + + it.each([ + ['missing status', undefined], + ['pending', 'PENDING'], + ['initiated', 'INITIATED'], + ['completed', 'COMPLETED'], + ['voided', 'VOIDED'], + ['unknown status', 'UNKNOWN'], + ['malformed status', { status: 'FAILED' }], + ])('retains the intent for %s', async (_, transactionStatus) => { + const error = new GraphQLErrorWithCodes([ + { + code: 'TRANSACTION_PROCESSING_FAILED', + extensions: { transactionStatus }, + }, + ]); + confirm + .mockRejectedValueOnce(actionRequiredError()) + .mockRejectedValueOnce(error); + const { result } = renderHook(() => useStripeCheckout({ mode }), { + wrapper: Wrapper, + }); + + await submitFailure(() => result.current.handleSubmit(), error); + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(confirm.mock.calls.map(([input]) => input.paymentToken)).toEqual([ + 'pm_original', + 'pi-confirmed', + 'pi-confirmed', + ]); + expect(mocks.createPaymentMethod).toHaveBeenCalledTimes(1); + }); + + it.each([ + [ + { + code: 'ORDER_OPENING_FAILED', + extensions: { transactionStatus: 'FAILED' }, + }, + ], + [ + { + code: 'TRANSACTION_PROCESSING_FAILED', + extensions: { transactionStatus: 'FAILED' }, + }, + { + code: 'TRANSACTION_PROCESSING_FAILED', + extensions: { transactionStatus: 'PENDING' }, + }, + ], + ])( + 'retains the intent for unrelated or conflicting failure metadata (%#)', + async (...errors) => { + const error = new GraphQLErrorWithCodes(errors); + confirm + .mockRejectedValueOnce(actionRequiredError()) + .mockRejectedValueOnce(error); + const { result } = renderHook(() => useStripeCheckout({ mode }), { + wrapper: Wrapper, + }); + + await submitFailure(() => result.current.handleSubmit(), error); + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(confirm).toHaveBeenLastCalledWith( + expect.objectContaining({ paymentToken: 'pi-confirmed' }) + ); + expect(mocks.createPaymentMethod).toHaveBeenCalledTimes(1); + } + ); + } +); + describe('useStripeCheckout payment request resolution', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts index c5d32caa..bd044272 100644 --- a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts @@ -124,8 +124,28 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { paymentType: string, confirm: (token: string) => Promise ) => { + const confirmPayment = async (token: string) => { + try { + await confirm(token); + } catch (error) { + // A generic confirmation failure may hide a completed payment. + // Only an explicit, unambiguous unpaid result permits replacement. + if ( + error instanceof GraphQLErrorWithCodes && + error.errors.length > 0 && + error.errors.every( + detail => + detail.code === 'TRANSACTION_PROCESSING_FAILED' && + detail.extensions?.transactionStatus === 'FAILED' + ) + ) { + pendingIntent.current = null; + } + throw error; + } + }; try { - await confirm(paymentToken); + await confirmPayment(paymentToken); pendingIntent.current = null; } catch (error) { if (isCheckoutConfirmationBlockedError(error)) throw error; @@ -181,7 +201,7 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { } challengeSucceeded = true; - await confirm(actionResult.paymentIntent.id); + await confirmPayment(actionResult.paymentIntent.id); pendingIntent.current = null; } finally { track({ diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index 1c5bd8b4..903e0478 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -2020,6 +2020,18 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "orderId", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "paymentMethods", "type": { @@ -9752,7 +9764,15 @@ const introspection = { "kind": "OBJECT", "name": "CheckoutSession" }, - "args": [], + "args": [ + { + "name": "id", + "type": { + "kind": "SCALAR", + "name": "ID" + } + } + ], "isDeprecated": false }, { diff --git a/packages/react/src/lib/graphql-with-errors.test.ts b/packages/react/src/lib/graphql-with-errors.test.ts index 4a54ffd6..8217002e 100644 --- a/packages/react/src/lib/graphql-with-errors.test.ts +++ b/packages/react/src/lib/graphql-with-errors.test.ts @@ -19,6 +19,36 @@ describe('graphqlRequestWithErrors', () => { requestMock.mockReset(); }); + it.each(['FAILED', 'PENDING', undefined])( + 'preserves optional transaction status %s without changing the error code', + async transactionStatus => { + const extensions = { + code: 'TRANSACTION_PROCESSING_FAILED', + ...(transactionStatus ? { transactionStatus } : {}), + }; + requestMock.mockRejectedValue( + new ClientError( + { + status: 200, + errors: [ + new GraphQLError('Failed to process transaction', { extensions }), + ], + }, + { + query: 'mutation ConfirmCheckout { confirmCheckoutSession { id } }', + } + ) + ); + + await expect( + graphqlRequestWithErrors('https://example.test/graphql', 'query') + ).rejects.toMatchObject({ + codes: ['TRANSACTION_PROCESSING_FAILED'], + errors: [{ code: 'TRANSACTION_PROCESSING_FAILED', extensions }], + }); + } + ); + it('preserves payment action-required extensions', async () => { requestMock.mockRejectedValue( new ClientError(