From 24015a9254474055a6dca366d484980ec5e3aabb Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Tue, 8 Sep 2026 14:19:28 -0500 Subject: [PATCH 01/15] 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/15] 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/15] 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( From 53776dc39bf8520abef70dd76ec86773b140410b Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Thu, 1 Oct 2026 10:58:19 -0500 Subject: [PATCH 14/15] Detect completed checkouts from orderStatus instead of draftOrder checkout-api returns a null draftOrder once an order is paid or awaiting offline payment, so the paid-order redirect and lost-response recovery never saw PAID, and a returning shopper with a paid order was sent to returnUrl. Read CheckoutSession.orderStatus through a separate query and redirect to successUrl when payment is PAID or PENDING (not CANCELED). Without orderStatus (older API) the lookup fails quietly and the existing returnUrl fallback applies. The test API mock now nulls draftOrder for non-mutable orders, matching checkout-api, so these paths are exercised as they behave in production. Co-Authored-By: Claude Opus 5.5 --- .../checkout-confirm-errors.test.tsx | 29 ++++--- .../__tests__/checkout-paid-order.test.tsx | 84 +++++++++++++++++-- .../__tests__/checkout-test-utils.tsx | 36 +++++++- .../express-checkout-paid-order.test.tsx | 17 ++-- .../express-checkout/express-checkout.tsx | 4 +- .../checkout/form/checkout-form-container.tsx | 26 ++++-- .../order/use-checkout-order-status.ts | 41 +++++++++ .../checkout/order/use-paid-order-redirect.ts | 16 ++-- .../utils/use-confirm-checkout-recovery.ts | 11 +-- .../use-confirm-express-checkout.test.tsx | 14 ++-- .../components/checkout/utils/query-keys.ts | 2 + .../react/src/lib/godaddy/checkout-env.ts | 9 ++ .../react/src/lib/godaddy/checkout-queries.ts | 14 ++++ packages/react/src/lib/godaddy/godaddy.ts | 42 ++++++++++ packages/react/src/tests/setup.ts | 1 + 15 files changed, 286 insertions(+), 60 deletions(-) create mode 100644 packages/react/src/components/checkout/order/use-checkout-order-status.ts diff --git a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx index 8e6876f0..868da75a 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx @@ -9,7 +9,7 @@ import { PaymentProvider, useConfirmCheckout, } from '@/components/checkout/payment/utils/use-confirm-checkout'; -import { confirmCheckout, getDraftOrder } from '@/lib/godaddy/godaddy'; +import { confirmCheckout } from '@/lib/godaddy/godaddy'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { buildCheckoutSession, @@ -24,6 +24,7 @@ import { type RenderCheckoutOptions, renderCheckout, setApiError, + setCurrentDraftOrder, waitForCheckoutReady, waitForOperation, } from './checkout-test-env'; @@ -100,7 +101,7 @@ function ConfirmSeamButton({ paymentType = isExpress ? 'apple_pay' : 'offline', paymentProvider = isExpress ? PaymentProvider.POYNT : PaymentProvider.OFFLINE, }: ConfirmSeamProps) { - const confirmCheckout = useConfirmCheckout(); + const confirmation = useConfirmCheckout(); const form = useFormContext(); const { setCheckoutErrors } = useCheckoutContext(); @@ -114,7 +115,7 @@ function ConfirmSeamButton({ form.setValue(key, value); } } - void confirmCheckout + void confirmation .mutateAsync({ paymentToken: isExpress ? 'express-nonce' : '', paymentType, @@ -142,7 +143,7 @@ function ConfirmSeamButton({ } function DuplicateConfirmSeamButton() { - const confirmCheckout = useConfirmCheckout(); + const confirmation = useConfirmCheckout(); const form = useFormContext(); const [secondResult, setSecondResult] = useState('idle'); @@ -158,8 +159,8 @@ function DuplicateConfirmSeamButton() { paymentProvider: PaymentProvider.OFFLINE, }; - void confirmCheckout.mutateAsync(input).catch(() => undefined); - void confirmCheckout + void confirmation.mutateAsync(input).catch(() => undefined); + void confirmation .mutateAsync(input) .then(() => setSecondResult('resolved')) .catch(err => { @@ -351,7 +352,7 @@ describe('Checkout confirm errors', () => { it('refreshes after a lost confirmation response and redirects the paid order', async () => { mockWindowLocation(); const successUrl = 'https://merchant.example/success'; - const { session, user } = renderCheckoutWithConfirmSeam( + const { user } = renderCheckoutWithConfirmSeam( { sessionOverrides: { ...offlineSessionOverrides(), successUrl }, }, @@ -363,14 +364,12 @@ describe('Checkout confirm errors', () => { ); await waitForCheckoutReady(); vi.mocked(confirmCheckout).mockImplementationOnce(async () => { - vi.mocked(getDraftOrder).mockResolvedValue({ - checkoutSession: { - ...session, - draftOrder: buildDraftOrder({ - statuses: { status: 'OPEN', paymentStatus: 'PAID' }, - }), - }, - }); + // The payment landed even though its response was lost. + setCurrentDraftOrder( + buildDraftOrder({ + statuses: { status: 'OPEN', paymentStatus: 'PAID' }, + }) + ); throw new Error('Confirmation response lost'); }); await user.click( diff --git a/packages/react/src/components/checkout/__tests__/checkout-paid-order.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-paid-order.test.tsx index 58e7e2d2..be675574 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-paid-order.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-paid-order.test.tsx @@ -1,8 +1,7 @@ -import { act, screen } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { - buildDraftOrder, getOperations, mockWindowLocation, renderCheckout, @@ -56,7 +55,76 @@ describe('Checkout paid-order recovery', () => { ).not.toBeInTheDocument(); }); - it.each(['UNPAID', 'PENDING'])( + it('sends a paid order to the success URL rather than the return URL', async () => { + const successUrl = 'https://merchant.example/success'; + renderCheckout({ + sessionOverrides: { + successUrl, + returnUrl: 'https://merchant.example/cart', + }, + draftOrderOverrides: { + statuses: { status: 'OPEN', paymentStatus: 'PAID' }, + }, + }); + + expect(await screen.findByRole('status')).toHaveTextContent( + 'Payment successful' + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(1100); + }); + expect(window.location.href).toBe(successUrl); + }); + + it.each([ + { + name: 'the API cannot report order status', + statuses: { status: 'OPEN', paymentStatus: 'PAID' }, + errors: { getCheckoutOrderStatus: new Error('Cannot query field') }, + }, + { + name: 'the order was canceled', + statuses: { status: 'CANCELED', paymentStatus: 'PAID' }, + errors: {}, + }, + ])( + 'falls back to the return URL without a draft order when $name', + async ({ statuses, errors }) => { + const returnUrl = 'https://merchant.example/cart'; + renderCheckout({ + sessionOverrides: { + successUrl: 'https://merchant.example/success', + returnUrl, + }, + draftOrderOverrides: { statuses }, + apiOverrides: { errors }, + }); + + await waitFor(() => expect(window.location.href).toBe(returnUrl)); + expect(screen.queryByText('Payment successful')).not.toBeInTheDocument(); + } + ); + + it('redirects an offline order whose payment is PENDING', async () => { + const successUrl = 'https://merchant.example/success'; + renderCheckout({ + sessionOverrides: { successUrl }, + draftOrderOverrides: { + statuses: { status: 'OPEN', paymentStatus: 'PENDING' }, + }, + }); + + expect(await screen.findByRole('status')).toHaveTextContent( + 'Payment successful' + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(1100); + }); + expect(window.location.href).toBe(successUrl); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + }); + + it.each(['UNPAID', 'PARTIALLY_PAID'])( 'does not redirect an order whose payment status is %s', async paymentStatus => { const initialUrl = window.location.href; @@ -73,7 +141,7 @@ describe('Checkout paid-order recovery', () => { } ); - it('uses the loaded order status when the session snapshot is still unpaid', async () => { + it('redirects when a refreshed order status becomes paid', async () => { const successUrl = 'https://merchant.example/success'; const { queryClient, session } = renderCheckout({ sessionOverrides: { successUrl }, @@ -81,12 +149,10 @@ describe('Checkout paid-order recovery', () => { }); await waitForCheckoutReady(); act(() => { - queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + queryClient.setQueryData(checkoutQueryKeys.orderStatus(session.id), { checkoutSession: { - ...session, - draftOrder: buildDraftOrder({ - statuses: { status: 'OPEN', paymentStatus: 'PAID' }, - }), + id: session.id, + orderStatus: { status: 'OPEN', paymentStatus: 'PAID' }, }, }); }); diff --git a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx index dc86dd3f..9a9e0c08 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx @@ -29,6 +29,7 @@ import { CheckoutType, PaymentMethodType, PaymentProvider } from '@/types'; export type OperationName = | 'CheckoutSession' | 'DraftOrder' + | 'CheckoutOrderStatus' | 'DraftOrderSkus' | 'DraftOrderShippingRates' | 'UpdateCheckoutSessionDraftOrder' @@ -64,6 +65,7 @@ export type MockGodaddyApiErrorKey = | 'refreshCheckoutToken' | 'getAddressMatches' | 'getDraftOrder' + | 'getCheckoutOrderStatus' | 'updateDraftOrder' | 'updateDraftOrderTaxes' | 'applyShippingMethod' @@ -564,12 +566,37 @@ function mergeDraftOrderPatch(input: Record) { state.session = { ...state.session, draftOrder: state.draftOrder }; } +// Mirrors checkout-api's assertMutable: draftOrder is null once the order is +// completed, canceled, paid, or awaiting an offline (PENDING) payment. +function isMutableOrder(draftOrder: DraftOrder) { + const status = draftOrder.statuses?.status?.toUpperCase(); + const paymentStatus = draftOrder.statuses?.paymentStatus?.toUpperCase(); + return ( + status !== 'COMPLETED' && + status !== 'CANCELED' && + paymentStatus !== 'PAID' && + paymentStatus !== 'PENDING' + ); +} + function makeDraftOrderResponse() { if (!state) throw new Error('mockGodaddyApi must be called first'); return { checkoutSession: { ...state.session, - draftOrder: state.draftOrder, + draftOrder: isMutableOrder(state.draftOrder) ? state.draftOrder : null, + }, + }; +} + +function makeOrderStatusResponse() { + if (!state) throw new Error('mockGodaddyApi must be called first'); + const { status = null, paymentStatus = null } = + state.draftOrder.statuses ?? {}; + return { + checkoutSession: { + id: state.session.id, + orderStatus: { status, paymentStatus }, }, }; } @@ -761,6 +788,13 @@ export function mockGodaddyApi(options: MockGodaddyApiOptions) { return makeDraftOrderResponse(); }); + mockedGodaddyApi.getCheckoutOrderStatus.mockImplementation(async () => { + record('CheckoutOrderStatus'); + await maybeDelay(); + maybeThrow('getCheckoutOrderStatus'); + return makeOrderStatusResponse(); + }); + mockedGodaddyApi.getProductsFromOrderSkus.mockImplementation(async () => { record('DraftOrderSkus'); await maybeDelay(); diff --git a/packages/react/src/components/checkout/__tests__/express-checkout-paid-order.test.tsx b/packages/react/src/components/checkout/__tests__/express-checkout-paid-order.test.tsx index 49de80aa..d4e07872 100644 --- a/packages/react/src/components/checkout/__tests__/express-checkout-paid-order.test.tsx +++ b/packages/react/src/components/checkout/__tests__/express-checkout-paid-order.test.tsx @@ -4,13 +4,14 @@ import { DraftOrderExpressCheckout } from '@/components/checkout/express-checkou 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 { confirmCheckout } from '@/lib/godaddy/godaddy'; import { buildCheckoutSession, buildDraftOrder, createTestQueryClient, mockGodaddyApi, mockWindowLocation, + setCurrentDraftOrder, } from './checkout-test-env'; vi.mock('@/components/checkout/payment/payment-method-renderer', () => ({ @@ -69,14 +70,12 @@ describe('Standalone express paid-order recovery', () => { name: 'Express pay', }); vi.mocked(confirmCheckout).mockImplementationOnce(async () => { - vi.mocked(getDraftOrder).mockResolvedValue({ - checkoutSession: { - ...session, - draftOrder: buildDraftOrder({ - statuses: { status: 'OPEN', paymentStatus: 'PAID' }, - }), - }, - }); + // The payment landed even though its response was lost. + setCurrentDraftOrder( + buildDraftOrder({ + statuses: { status: 'OPEN', paymentStatus: 'PAID' }, + }) + ); throw new Error('Confirmation response lost'); }); fireEvent.click(button); 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 dca8cb30..571ab6be 100644 --- a/packages/react/src/components/checkout/express-checkout/express-checkout.tsx +++ b/packages/react/src/components/checkout/express-checkout/express-checkout.tsx @@ -89,11 +89,11 @@ function DraftOrderExpressCheckoutButtons() { function ExpressCheckoutContent() { const { t } = useGoDaddyContext(); const { data: order, isLoading } = useDraftOrder(); - const showPaidOrder = usePaidOrderRedirect(order); + const { showPaidOrder, isLoadingOrderStatus } = usePaidOrderRedirect(); if (showPaidOrder) return
{t.errors.paymentSuccessful}
; - if (isLoading) return null; + if (isLoading || (!order && isLoadingOrderStatus)) return null; return ( 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 f1e282f0..27a13dc4 100644 --- a/packages/react/src/components/checkout/form/checkout-form-container.tsx +++ b/packages/react/src/components/checkout/form/checkout-form-container.tsx @@ -40,7 +40,7 @@ export function CheckoutFormContainer({ const skusMap = useDraftOrderProductsMap(); const { data: order } = draftOrderQuery; - const showPaidOrder = usePaidOrderRedirect(order); + const { showPaidOrder, isLoadingOrderStatus } = usePaidOrderRedirect(); const { data: lineItems } = draftOrderLineItemsQuery; useRefreshProductsWhenLineItemsChange(lineItems); @@ -73,7 +73,18 @@ export function CheckoutFormContainer({ ] ); - if (!isConfirmingCheckout && !draftOrderQuery.isLoading && !order) { + // A paid or pending order has no draftOrder, so check completion before + // treating a missing order as abandoned and returning to the merchant. + if (showPaidOrder) { + return
{t.errors.paymentSuccessful}
; + } + + if ( + !isConfirmingCheckout && + !draftOrderQuery.isLoading && + !order && + !isLoadingOrderStatus + ) { const returnUrl = session?.returnUrl; if (returnUrl) { window.location.href = returnUrl; @@ -81,16 +92,17 @@ export function CheckoutFormContainer({ } } - if (props.isLoading || draftOrderQuery.isLoading || isLoadingJWT) { + if ( + props.isLoading || + draftOrderQuery.isLoading || + isLoadingJWT || + (!order && isLoadingOrderStatus) + ) { return ( props.loadingFallback ?? ); } - if (showPaidOrder) { - return
{t.errors.paymentSuccessful}
; - } - return ( + jwt + ? getCheckoutOrderStatus({ accessToken: jwt }, apiHost) + : getCheckoutOrderStatus(session, apiHost), + enabled: !!session?.id, + staleTime: 5_000, + select: data => data.checkoutSession?.orderStatus ?? null, + // An API without orderStatus fails validation; retrying cannot help, and a + // failed lookup only means checkout is not known to be complete. + retry: false, + refetchOnWindowFocus: 'always', + }); +} 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 index a0ce47c8..7f17aaa7 100644 --- a/packages/react/src/components/checkout/order/use-paid-order-redirect.ts +++ b/packages/react/src/components/checkout/order/use-paid-order-redirect.ts @@ -3,17 +3,21 @@ import { redirectToSuccessUrl, useCheckoutContext, } from '@/components/checkout/checkout'; -import type { DraftOrder } from '@/types'; +import { + isCheckoutComplete, + useCheckoutOrderStatus, +} from './use-checkout-order-status'; -export function usePaidOrderRedirect(order: DraftOrder | null | undefined) { +// draftOrder is null once an order is paid, so completion comes from orderStatus. +export function usePaidOrderRedirect() { const { session, isConfirmingCheckout } = useCheckoutContext(); - const isPaid = - order?.statuses?.paymentStatus?.trim().toUpperCase() === 'PAID'; - const showPaidOrder = isPaid && !isConfirmingCheckout; + const { data: orderStatus, isLoading } = useCheckoutOrderStatus(); + const showPaidOrder = + isCheckoutComplete(orderStatus) && !isConfirmingCheckout; useEffect(() => { if (showPaidOrder) redirectToSuccessUrl(session?.successUrl); }, [showPaidOrder, session?.successUrl]); - return showPaidOrder; + return { showPaidOrder, isLoadingOrderStatus: isLoading }; } 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 index bb928c15..b1b33f6c 100644 --- 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 @@ -2,11 +2,12 @@ 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 { getCheckoutOrderStatus } 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. +// the authoritative order status before callers unlock checkout and offer another +// try; a completed order then redirects through usePaidOrderRedirect. export function useConfirmCheckoutRecovery() { const queryClient = useQueryClient(); const { session, jwt } = useCheckoutContext(); @@ -20,15 +21,15 @@ export function useConfirmCheckoutRecovery() { } catch (error) { if (session?.id && !getPaymentActionRequiredResult(error)) { try { - const queryKey = checkoutQueryKeys.draftOrder(session.id); + const queryKey = checkoutQueryKeys.orderStatus(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), + ? getCheckoutOrderStatus({ accessToken: jwt }, apiHost) + : getCheckoutOrderStatus(session, apiHost), staleTime: 0, retry: false, }); 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 b9a9be3f..756fd6a9 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 @@ -11,7 +11,7 @@ import { 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 { confirmCheckout, getCheckoutOrderStatus } from '@/lib/godaddy/godaddy'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { PaymentMethodType } from '@/types'; import { StripeProvider } from './stripe-provider'; @@ -198,8 +198,8 @@ describe('useConfirmExpressCheckout', () => { 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( + let rejectLookup!: (reason: Error) => void; + vi.mocked(getCheckoutOrderStatus).mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectLookup = reject; @@ -219,7 +219,9 @@ describe('useConfirmExpressCheckout', () => { paymentProvider: PaymentProvider.POYNT, }) .catch(caught => caught); - await waitFor(() => expect(getDraftOrder).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(getCheckoutOrderStatus).toHaveBeenCalledTimes(1) + ); expect(result.current.context.isConfirmingCheckout).toBe(true); rejectLookup(new Error('Status lookup failed')); expect(await outcome).toBe(error); @@ -255,7 +257,7 @@ describe('useConfirmExpressCheckout', () => { paymentProvider: PaymentProvider.STRIPE, }) ).rejects.toBe(error); - expect(getDraftOrder).not.toHaveBeenCalled(); + expect(getCheckoutOrderStatus).not.toHaveBeenCalled(); }); }); @@ -310,7 +312,7 @@ it('keeps express checkout locked during 3DS and allows only the matching intent 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(getCheckoutOrderStatus).not.toHaveBeenCalled(); expect(confirmCheckout).toHaveBeenCalledTimes(1); await act(async () => { await expect( diff --git a/packages/react/src/components/checkout/utils/query-keys.ts b/packages/react/src/components/checkout/utils/query-keys.ts index cf2c8d2d..07d854fd 100644 --- a/packages/react/src/components/checkout/utils/query-keys.ts +++ b/packages/react/src/components/checkout/utils/query-keys.ts @@ -32,4 +32,6 @@ export const checkoutQueryKeys = { ['draft-order-products', { sessionId }] as const, draftOrderShippingMethods: (sessionId?: string | null) => ['draft-order-shipping-methods', { sessionId }] as const, + orderStatus: (sessionId?: string | null) => + ['checkout-order-status', { sessionId }] as const, }; diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index 903e0478..3c4a24ed 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -2032,6 +2032,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "orderStatus", + "type": { + "kind": "OBJECT", + "name": "DraftOrderStatuses" + }, + "args": [], + "isDeprecated": false + }, { "name": "paymentMethods", "type": { diff --git a/packages/react/src/lib/godaddy/checkout-queries.ts b/packages/react/src/lib/godaddy/checkout-queries.ts index 42e342f8..b4c49950 100644 --- a/packages/react/src/lib/godaddy/checkout-queries.ts +++ b/packages/react/src/lib/godaddy/checkout-queries.ts @@ -322,6 +322,20 @@ export const AddressMatchesQuery = graphql(` } `); +// Unlike draftOrder, orderStatus stays available once checkout is finished +// (paid or pending offline payment), so it is safe to poll after confirmation. +export const CheckoutOrderStatusQuery = graphql(` + query CheckoutOrderStatus { + checkoutSession { + id + orderStatus { + status + paymentStatus + } + } + } +`); + export const DraftOrderQuery = graphql(` query DraftOrder { checkoutSession { diff --git a/packages/react/src/lib/godaddy/godaddy.ts b/packages/react/src/lib/godaddy/godaddy.ts index dc2dfc46..872fdabf 100644 --- a/packages/react/src/lib/godaddy/godaddy.ts +++ b/packages/react/src/lib/godaddy/godaddy.ts @@ -57,6 +57,7 @@ import { } from './checkout-mutations.ts'; import { AddressMatchesQuery, + CheckoutOrderStatusQuery, DraftOrderPriceAdjustmentsQuery, DraftOrderQuery, DraftOrderShippingRatesQuery, @@ -314,6 +315,47 @@ export function getDraftOrder( ); } +export function getCheckoutOrderStatus( + sessionOrAuth: + | CheckoutSession + | undefined + | null + | { accessToken: string | undefined }, + apiHost?: string +): Promise> { + const GODADDY_HOST = getHostByEnvironment(apiHost); + + if (sessionOrAuth && 'accessToken' in sessionOrAuth) { + if (!sessionOrAuth.accessToken) { + throw new Error('No access token provided'); + } + return graphqlRequestWithErrors>( + GODADDY_HOST, + CheckoutOrderStatusQuery, + undefined, + { + Authorization: `Bearer ${sessionOrAuth.accessToken}`, + } + ); + } + + const session = sessionOrAuth; + if (!session?.token || !session?.id) { + throw new Error('No session token or ID provided'); + } + + return graphqlRequestWithErrors>( + GODADDY_HOST, + CheckoutOrderStatusQuery, + undefined, + { + 'x-session-token': `${session.token}`, + 'x-session-id': session.id, + 'x-store-id': session.storeId, + } + ); +} + export function getDraftOrderTaxes( session: CheckoutSession | undefined, params: { diff --git a/packages/react/src/tests/setup.ts b/packages/react/src/tests/setup.ts index 835bc678..23a5e979 100644 --- a/packages/react/src/tests/setup.ts +++ b/packages/react/src/tests/setup.ts @@ -7,6 +7,7 @@ vi.mock('@/lib/godaddy/godaddy', () => ({ getCheckoutSession: vi.fn(), getAddressMatches: vi.fn(), getDraftOrder: vi.fn(), + getCheckoutOrderStatus: vi.fn(), getDraftOrderTaxes: vi.fn(), verifyAddress: vi.fn(), updateDraftOrder: vi.fn(), From a89eb45d2402f049581f40e45de3f7dfdedc2a58 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Thu, 1 Oct 2026 12:31:09 -0500 Subject: [PATCH 15/15] Close the Stripe wallet sheet on blocked confirmations A blocked express confirmation returned without calling paymentFailed, so the wallet sheet waited for an outcome that never came. It now fails the sheet without tracking a payment error. The order-status lookup ran on every checkout load and window focus. A focus refetch counted as in-flight work and could block an express confirmation started as a wallet popup closed. Fetch it only when the draft order is unavailable (always the case for paid or pending orders), never on focus; confirmation recovery still fills it directly. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/checkout-paid-order.test.tsx | 26 ++++ .../express-checkout/express-checkout.tsx | 5 +- .../checkout/form/checkout-form-container.tsx | 5 +- .../order/use-checkout-order-status.ts | 10 +- .../checkout/order/use-paid-order-redirect.ts | 25 +++- .../express/stripe-confirm.test.tsx | 121 ++++++++++++++++++ .../checkout-buttons/express/stripe.tsx | 10 +- 7 files changed, 191 insertions(+), 11 deletions(-) create mode 100644 packages/react/src/components/checkout/payment/checkout-buttons/express/stripe-confirm.test.tsx diff --git a/packages/react/src/components/checkout/__tests__/checkout-paid-order.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-paid-order.test.tsx index be675574..e24e8494 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-paid-order.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-paid-order.test.tsx @@ -55,6 +55,32 @@ describe('Checkout paid-order recovery', () => { ).not.toBeInTheDocument(); }); + it('only looks up order status when the draft order is unavailable', async () => { + renderCheckout({ + draftOrderOverrides: { statuses: { paymentStatus: 'UNPAID' } }, + }); + await waitForCheckoutReady(); + act(() => { + window.dispatchEvent(new Event('focus')); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1100); + }); + expect(getOperations('CheckoutOrderStatus')).toHaveLength(0); + }); + + it('looks up order status once for a paid order', async () => { + renderCheckout({ + draftOrderOverrides: { + statuses: { status: 'OPEN', paymentStatus: 'PAID' }, + }, + }); + expect(await screen.findByRole('status')).toHaveTextContent( + 'Payment successful' + ); + expect(getOperations('CheckoutOrderStatus')).toHaveLength(1); + }); + it('sends a paid order to the success URL rather than the return URL', async () => { const successUrl = 'https://merchant.example/success'; renderCheckout({ 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 571ab6be..80c2a638 100644 --- a/packages/react/src/components/checkout/express-checkout/express-checkout.tsx +++ b/packages/react/src/components/checkout/express-checkout/express-checkout.tsx @@ -89,7 +89,10 @@ function DraftOrderExpressCheckoutButtons() { function ExpressCheckoutContent() { const { t } = useGoDaddyContext(); const { data: order, isLoading } = useDraftOrder(); - const { showPaidOrder, isLoadingOrderStatus } = usePaidOrderRedirect(); + const { showPaidOrder, isLoadingOrderStatus } = usePaidOrderRedirect({ + order, + isDraftOrderLoading: isLoading, + }); if (showPaidOrder) return
{t.errors.paymentSuccessful}
; 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 27a13dc4..51280845 100644 --- a/packages/react/src/components/checkout/form/checkout-form-container.tsx +++ b/packages/react/src/components/checkout/form/checkout-form-container.tsx @@ -40,7 +40,10 @@ export function CheckoutFormContainer({ const skusMap = useDraftOrderProductsMap(); const { data: order } = draftOrderQuery; - const { showPaidOrder, isLoadingOrderStatus } = usePaidOrderRedirect(); + const { showPaidOrder, isLoadingOrderStatus } = usePaidOrderRedirect({ + order, + isDraftOrderLoading: draftOrderQuery.isLoading, + }); const { data: lineItems } = draftOrderLineItemsQuery; useRefreshProductsWhenLineItemsChange(lineItems); diff --git a/packages/react/src/components/checkout/order/use-checkout-order-status.ts b/packages/react/src/components/checkout/order/use-checkout-order-status.ts index 1cfc598b..817d7ef9 100644 --- a/packages/react/src/components/checkout/order/use-checkout-order-status.ts +++ b/packages/react/src/components/checkout/order/use-checkout-order-status.ts @@ -20,7 +20,7 @@ export function isCheckoutComplete(orderStatus: OrderStatus | undefined) { ); } -export function useCheckoutOrderStatus() { +export function useCheckoutOrderStatus({ enabled }: { enabled: boolean }) { const { session, jwt } = useCheckoutContext(); const { apiHost } = useGoDaddyContext(); @@ -30,12 +30,16 @@ export function useCheckoutOrderStatus() { jwt ? getCheckoutOrderStatus({ accessToken: jwt }, apiHost) : getCheckoutOrderStatus(session, apiHost), - enabled: !!session?.id, + // Callers enable this only when it can matter; confirmation recovery fills + // the same cache entry directly with fetchQuery. + enabled: enabled && !!session?.id, staleTime: 5_000, select: data => data.checkoutSession?.orderStatus ?? null, // An API without orderStatus fails validation; retrying cannot help, and a // failed lookup only means checkout is not known to be complete. retry: false, - refetchOnWindowFocus: 'always', + // A focus refetch would count as in-flight work and block an express + // confirmation started right after a wallet sheet closes. + refetchOnWindowFocus: false, }); } 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 index 7f17aaa7..b7c5e588 100644 --- a/packages/react/src/components/checkout/order/use-paid-order-redirect.ts +++ b/packages/react/src/components/checkout/order/use-paid-order-redirect.ts @@ -3,21 +3,36 @@ import { redirectToSuccessUrl, useCheckoutContext, } from '@/components/checkout/checkout'; +import type { DraftOrder } from '@/types'; import { isCheckoutComplete, useCheckoutOrderStatus, } from './use-checkout-order-status'; -// draftOrder is null once an order is paid, so completion comes from orderStatus. -export function usePaidOrderRedirect() { +// draftOrder is null once an order is paid or awaiting offline payment, so a +// missing draft order is the only time completion needs to be looked up. +export function usePaidOrderRedirect({ + order, + isDraftOrderLoading, +}: { + order: DraftOrder | null | undefined; + isDraftOrderLoading: boolean; +}) { const { session, isConfirmingCheckout } = useCheckoutContext(); - const { data: orderStatus, isLoading } = useCheckoutOrderStatus(); + const needsOrderStatus = !isDraftOrderLoading && !order; + const orderStatusQuery = useCheckoutOrderStatus({ + enabled: needsOrderStatus, + }); const showPaidOrder = - isCheckoutComplete(orderStatus) && !isConfirmingCheckout; + isCheckoutComplete(orderStatusQuery.data) && !isConfirmingCheckout; useEffect(() => { if (showPaidOrder) redirectToSuccessUrl(session?.successUrl); }, [showPaidOrder, session?.successUrl]); - return { showPaidOrder, isLoadingOrderStatus: isLoading }; + return { + showPaidOrder, + // Pending until the lookup settles, including the render that enables it. + isLoadingOrderStatus: needsOrderStatus && orderStatusQuery.isPending, + }; } diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe-confirm.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe-confirm.test.tsx new file mode 100644 index 00000000..6ac8f345 --- /dev/null +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe-confirm.test.tsx @@ -0,0 +1,121 @@ +import type { StripeExpressCheckoutElementConfirmEvent } from '@stripe/stripe-js'; +import { render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CheckoutConfirmationBlockedError } from '@/components/checkout/payment/utils/use-confirm-checkout'; +import { StripeExpressCheckoutForm } from './stripe'; + +const mocks = vi.hoisted(() => ({ + handleSubmit: vi.fn(), + track: vi.fn(), + onConfirm: undefined as + | ((event: StripeExpressCheckoutElementConfirmEvent) => Promise) + | undefined, +})); + +vi.mock('@stripe/react-stripe-js', () => ({ + ExpressCheckoutElement: (props: { onConfirm: typeof mocks.onConfirm }) => { + mocks.onConfirm = props.onConfirm; + return null; + }, + useElements: () => null, +})); +vi.mock('@/components/checkout/checkout', () => ({ + useCheckoutContext: () => ({ + session: { id: 'session-1' }, + setCheckoutErrors: vi.fn(), + isConfirmingCheckout: false, + }), +})); +vi.mock('@/godaddy-provider', () => ({ + useGoDaddyContext: () => ({ + t: { + errors: { errorProcessingPayment: 'Error processing payment' }, + totals: { + discount: 'Discount', + estimatedTaxes: 'Estimated taxes', + shipping: 'Shipping', + subtotal: 'Subtotal', + }, + }, + }), +})); +vi.mock('@/components/checkout/payment/utils/use-stripe-checkout', () => ({ + useStripeCheckout: () => ({ handleSubmit: mocks.handleSubmit }), +})); +vi.mock('@/components/checkout/order/use-draft-order', () => ({ + useDraftOrder: () => ({ data: undefined }), + useDraftOrderTotals: () => ({ data: undefined }), +})); +vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({ + useIsPaymentDisabled: () => false, +})); +vi.mock( + '@/components/checkout/shipping/utils/use-get-shipping-methods', + () => ({ useGetShippingMethodByAddress: () => vi.fn() }) +); +vi.mock('@/components/checkout/taxes/utils/use-get-taxes', () => ({ + useGetTaxes: () => vi.fn(), +})); +vi.mock( + '@/components/checkout/discount/utils/use-get-price-adjustments', + () => ({ useGetPriceAdjustments: () => vi.fn() }) +); +vi.mock('@/tracking/track', () => ({ + track: mocks.track, + TrackingEventType: { EVENT: 'event' }, +})); + +function confirmEvent() { + return { + expressPaymentType: 'google_pay', + paymentFailed: vi.fn(), + } as unknown as StripeExpressCheckoutElementConfirmEvent & { + paymentFailed: ReturnType; + }; +} + +describe('StripeExpressCheckoutForm confirm', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.onConfirm = undefined; + render(); + }); + + it('closes the wallet sheet when confirmation is blocked, without reporting a payment error', async () => { + mocks.handleSubmit.mockRejectedValueOnce( + new CheckoutConfirmationBlockedError('Checkout is currently busy') + ); + const event = confirmEvent(); + + await mocks.onConfirm?.(event); + + expect(event.paymentFailed).toHaveBeenCalledWith({ + reason: 'fail', + message: 'Error processing payment', + }); + expect(mocks.track).not.toHaveBeenCalled(); + }); + + it('closes the wallet sheet and tracks the error when payment fails', async () => { + mocks.handleSubmit.mockRejectedValueOnce(new Error('declined')); + const event = confirmEvent(); + + await mocks.onConfirm?.(event); + + expect(event.paymentFailed).toHaveBeenCalledTimes(1); + expect(mocks.track).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ errorType: 'declined' }), + }) + ); + }); + + it('leaves the wallet sheet to the payment flow on success', async () => { + mocks.handleSubmit.mockResolvedValueOnce(undefined); + const event = confirmEvent(); + + await mocks.onConfirm?.(event); + + expect(event.paymentFailed).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx index 767b4dd5..5b05b522 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx @@ -587,7 +587,15 @@ export function StripeExpressCheckoutForm() { selectedShippingMethod: selectedShippingMethod || null, }); } catch (error) { - if (isCheckoutConfirmationBlockedError(error)) return; + // A blocked confirmation is not a payment error, but the wallet sheet + // still waits for an outcome, so it must be told the payment failed. + if (isCheckoutConfirmationBlockedError(error)) { + event.paymentFailed({ + reason: 'fail', + message: t.errors.errorProcessingPayment, + }); + return; + } // Track error track({ eventId: eventIds.expressCheckoutError,