From 24015a9254474055a6dca366d484980ec5e3aabb Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Tue, 8 Sep 2026 14:19:28 -0500 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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, From 4b77d5864fb38d246e4a7ea0c49df304c424ca1f Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Mon, 26 Oct 2026 12:53:06 -0500 Subject: [PATCH 16/18] Block a second payment while a 3-D Secure payment is unresolved Address review feedback on Stripe 3-D Secure support: - Card and express kept separate pending intents, so switching to a wallet after an unclear 3-D Secure outcome could take a second payment. Track the unresolved intent per session in the React Query cache, resumed only by the flow that created it (card and express carry different totals). Confirm and authorize block every other payment method, before anything is sent, with PAYMENT_STILL_CONFIRMING. The express continuation check reads the same entry, so a remount mid-challenge no longer leaves checkout locked. - A cached UNPAID order status could send a paid shopper to returnUrl once the draft order disappeared. Always look the status up again when it's needed, and wait for that lookup before falling back. - Stripe flows replaced codes the confirm hook had already shown (MISSING_SHIPPING_INFO, DRAFT_ORDER_UPDATE_FAILED) with TRANSACTION_PROCESSING_FAILED. Mark those errors as already reported; other failures, such as a bodyless 502 after the challenge, still show TRANSACTION_PROCESSING_FAILED. Co-Authored-By: Claude Opus 5.5 --- .changeset/bright-cards-handle-3ds.md | 2 +- packages/localizations/src/deDe.ts | 2 + packages/localizations/src/enAu.ts | 2 + packages/localizations/src/enIe.ts | 2 + packages/localizations/src/enUs.ts | 2 + packages/localizations/src/esAr.ts | 2 + packages/localizations/src/esCl.ts | 2 + packages/localizations/src/esCo.ts | 2 + packages/localizations/src/esEs.ts | 2 + packages/localizations/src/esMx.ts | 2 + packages/localizations/src/esPe.ts | 2 + packages/localizations/src/esUs.ts | 2 + packages/localizations/src/frCa.ts | 2 + packages/localizations/src/frFr.ts | 2 + packages/localizations/src/idId.ts | 2 + packages/localizations/src/itIt.ts | 2 + packages/localizations/src/ptBr.ts | 2 + packages/localizations/src/qaPs.ts | 2 + packages/localizations/src/trTr.ts | 2 + packages/localizations/src/viVn.ts | 2 + packages/localizations/src/zhCn.ts | 2 + packages/localizations/src/zhSg.ts | 2 + .../checkout-confirm-errors.test.tsx | 25 ++++ .../__tests__/checkout-paid-order.test.tsx | 40 ++++++ .../order/use-checkout-order-status.ts | 5 +- .../checkout/order/use-paid-order-redirect.ts | 10 +- .../payment/utils/checkout-error-reported.ts | 16 +++ .../payment/utils/stripe-provider.tsx | 44 ++----- .../payment/utils/unresolved-payment.ts | 121 ++++++++++++++++++ .../utils/use-authorize-checkout.test.tsx | 26 ++++ .../payment/utils/use-authorize-checkout.ts | 15 ++- .../payment/utils/use-confirm-checkout.ts | 23 +++- .../utils/use-confirm-express-checkout.ts | 37 +++--- .../payment/utils/use-flush-checkout-sync.ts | 7 +- .../use-stripe-checkout.integration.test.tsx | 107 ++++++++++++++-- .../payment/utils/use-stripe-checkout.ts | 61 +++++---- .../components/checkout/utils/query-keys.ts | 2 + 37 files changed, 492 insertions(+), 91 deletions(-) create mode 100644 packages/react/src/components/checkout/payment/utils/checkout-error-reported.ts create mode 100644 packages/react/src/components/checkout/payment/utils/unresolved-payment.ts diff --git a/.changeset/bright-cards-handle-3ds.md b/.changeset/bright-cards-handle-3ds.md index 628b005c..eb9d121b 100644 --- a/.changeset/bright-cards-handle-3ds.md +++ b/.changeset/bright-cards-handle-3ds.md @@ -5,4 +5,4 @@ Add Stripe 3DS verification support for card and express checkout, with localized payment errors and compatibility with existing checkout error handling. -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. +Improve payment retry handling, prevent duplicate submissions, and recognize already-paid orders. While a 3-D Secure payment's outcome is unknown, every payment method is blocked from starting another payment for the checkout. Update express payment tracking to reflect the payment method used and successful checkout completion. diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts index 693ad835..6143f1c0 100644 --- a/packages/localizations/src/deDe.ts +++ b/packages/localizations/src/deDe.ts @@ -291,6 +291,8 @@ export const deDe = { DRAFT_ORDER_RETRIEVAL_FAILED: 'Abrufen der Bestellung fehlgeschlagen', DATA_SOURCES_NOT_FOUND: 'Datenquellen nicht gefunden', TRANSACTION_API_NOT_FOUND: 'Transaktions-API-Datenquelle nicht gefunden', + PAYMENT_STILL_CONFIRMING: + 'Eine vorherige Zahlung für diese Bestellung wird noch bestätigt. Warte einen Moment oder lade die Seite neu, um den Status zu prüfen.', PAYMENT_ACTION_REQUIRED: 'Deine Zahlung erfordert eine zusätzliche Bestätigung. Versuche es erneut oder wähle eine andere Zahlungsmethode.', TRANSACTION_PROCESSING_FAILED: diff --git a/packages/localizations/src/enAu.ts b/packages/localizations/src/enAu.ts index 489c8bf0..c844830e 100644 --- a/packages/localizations/src/enAu.ts +++ b/packages/localizations/src/enAu.ts @@ -278,6 +278,8 @@ export const enAu = { DRAFT_ORDER_RETRIEVAL_FAILED: 'Failed to retrieve order', DATA_SOURCES_NOT_FOUND: 'Data sources not found', TRANSACTION_API_NOT_FOUND: 'Transaction API data source not found', + PAYMENT_STILL_CONFIRMING: + 'A previous payment for this order is still being confirmed. Please wait a moment, or refresh the page to check its status.', PAYMENT_ACTION_REQUIRED: 'Your payment needs additional verification. Please try again or choose another payment method.', TRANSACTION_PROCESSING_FAILED: 'Failed to process transaction', diff --git a/packages/localizations/src/enIe.ts b/packages/localizations/src/enIe.ts index 223fd71e..06e1aee3 100644 --- a/packages/localizations/src/enIe.ts +++ b/packages/localizations/src/enIe.ts @@ -278,6 +278,8 @@ export const enIe = { DRAFT_ORDER_RETRIEVAL_FAILED: 'Failed to retrieve order', DATA_SOURCES_NOT_FOUND: 'Data sources not found', TRANSACTION_API_NOT_FOUND: 'Transaction API data source not found', + PAYMENT_STILL_CONFIRMING: + 'A previous payment for this order is still being confirmed. Please wait a moment, or refresh the page to check its status.', PAYMENT_ACTION_REQUIRED: 'Your payment needs additional verification. Please try again or choose another payment method.', TRANSACTION_PROCESSING_FAILED: 'Failed to process transaction', diff --git a/packages/localizations/src/enUs.ts b/packages/localizations/src/enUs.ts index 46afd1ed..ddc661bd 100644 --- a/packages/localizations/src/enUs.ts +++ b/packages/localizations/src/enUs.ts @@ -278,6 +278,8 @@ export const enUs = { DRAFT_ORDER_RETRIEVAL_FAILED: 'Failed to retrieve order', DATA_SOURCES_NOT_FOUND: 'Data sources not found', TRANSACTION_API_NOT_FOUND: 'Transaction API data source not found', + PAYMENT_STILL_CONFIRMING: + 'A previous payment for this order is still being confirmed. Please wait a moment, or refresh the page to check its status.', PAYMENT_ACTION_REQUIRED: 'Your payment needs additional verification. Please try again or choose another payment method.', TRANSACTION_PROCESSING_FAILED: 'Failed to process transaction', diff --git a/packages/localizations/src/esAr.ts b/packages/localizations/src/esAr.ts index dc11d7b6..4d0b2ab0 100644 --- a/packages/localizations/src/esAr.ts +++ b/packages/localizations/src/esAr.ts @@ -284,6 +284,8 @@ export const esAr = { DATA_SOURCES_NOT_FOUND: 'Fuentes de datos no encontradas', TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacciones no encontrada', + PAYMENT_STILL_CONFIRMING: + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento o actualiza la página para ver su estado.', PAYMENT_ACTION_REQUIRED: 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', diff --git a/packages/localizations/src/esCl.ts b/packages/localizations/src/esCl.ts index f46b1c60..cf154f8f 100644 --- a/packages/localizations/src/esCl.ts +++ b/packages/localizations/src/esCl.ts @@ -286,6 +286,8 @@ export const esCl = { DATA_SOURCES_NOT_FOUND: 'Fuentes de datos no encontradas', TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacción no encontrada', + PAYMENT_STILL_CONFIRMING: + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento o actualiza la página para ver su estado.', PAYMENT_ACTION_REQUIRED: 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', diff --git a/packages/localizations/src/esCo.ts b/packages/localizations/src/esCo.ts index acdc1229..1c117a00 100644 --- a/packages/localizations/src/esCo.ts +++ b/packages/localizations/src/esCo.ts @@ -284,6 +284,8 @@ export const esCo = { DATA_SOURCES_NOT_FOUND: 'Fuentes de datos no encontradas', TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacciones no encontrada', + PAYMENT_STILL_CONFIRMING: + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento o actualiza la página para ver su estado.', PAYMENT_ACTION_REQUIRED: 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', diff --git a/packages/localizations/src/esEs.ts b/packages/localizations/src/esEs.ts index 440d8b7a..51265625 100644 --- a/packages/localizations/src/esEs.ts +++ b/packages/localizations/src/esEs.ts @@ -287,6 +287,8 @@ export const esEs = { DATA_SOURCES_NOT_FOUND: 'Fuentes de datos no encontradas', TRANSACTION_API_NOT_FOUND: 'Fuente de datos de la API de transacciones no encontrada', + PAYMENT_STILL_CONFIRMING: + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento o actualiza la página para ver su estado.', PAYMENT_ACTION_REQUIRED: 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'No se pudo procesar la transacción', diff --git a/packages/localizations/src/esMx.ts b/packages/localizations/src/esMx.ts index 7626582a..ada38afc 100644 --- a/packages/localizations/src/esMx.ts +++ b/packages/localizations/src/esMx.ts @@ -284,6 +284,8 @@ export const esMx = { DATA_SOURCES_NOT_FOUND: 'Fuentes de datos no encontradas', TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacciones no encontrada', + PAYMENT_STILL_CONFIRMING: + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento o actualiza la página para ver su estado.', PAYMENT_ACTION_REQUIRED: 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', diff --git a/packages/localizations/src/esPe.ts b/packages/localizations/src/esPe.ts index ce5b55a8..f6cd4b47 100644 --- a/packages/localizations/src/esPe.ts +++ b/packages/localizations/src/esPe.ts @@ -284,6 +284,8 @@ export const esPe = { DATA_SOURCES_NOT_FOUND: 'Fuentes de datos no encontradas', TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacciones no encontrada', + PAYMENT_STILL_CONFIRMING: + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento o actualiza la página para ver su estado.', PAYMENT_ACTION_REQUIRED: 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', diff --git a/packages/localizations/src/esUs.ts b/packages/localizations/src/esUs.ts index ad3e6bfd..fcca79ff 100644 --- a/packages/localizations/src/esUs.ts +++ b/packages/localizations/src/esUs.ts @@ -284,6 +284,8 @@ export const esUs = { DATA_SOURCES_NOT_FOUND: 'Fuentes de datos no encontradas', TRANSACTION_API_NOT_FOUND: 'Fuente de datos de Transaction API no encontrada', + PAYMENT_STILL_CONFIRMING: + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento o actualiza la página para ver su estado.', PAYMENT_ACTION_REQUIRED: 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', diff --git a/packages/localizations/src/frCa.ts b/packages/localizations/src/frCa.ts index dc675271..58ac89e7 100644 --- a/packages/localizations/src/frCa.ts +++ b/packages/localizations/src/frCa.ts @@ -293,6 +293,8 @@ export const frCa = { DATA_SOURCES_NOT_FOUND: 'Sources de données introuvables', TRANSACTION_API_NOT_FOUND: 'Source de données API de transaction introuvable', + PAYMENT_STILL_CONFIRMING: + 'Un paiement précédent pour cette commande est toujours en cours de confirmation. Patientez un instant ou actualisez la page pour vérifier son état.', PAYMENT_ACTION_REQUIRED: 'Votre paiement nécessite une vérification supplémentaire. Réessayez ou choisissez un autre moyen de paiement.', TRANSACTION_PROCESSING_FAILED: 'Échec du traitement de la transaction', diff --git a/packages/localizations/src/frFr.ts b/packages/localizations/src/frFr.ts index 8fa34c4f..0d55129b 100644 --- a/packages/localizations/src/frFr.ts +++ b/packages/localizations/src/frFr.ts @@ -293,6 +293,8 @@ export const frFr = { DATA_SOURCES_NOT_FOUND: 'Sources de données non trouvées', TRANSACTION_API_NOT_FOUND: "Source de données de l'API de transaction non trouvée", + PAYMENT_STILL_CONFIRMING: + 'Un paiement précédent pour cette commande est toujours en cours de confirmation. Patientez un instant ou actualisez la page pour vérifier son état.', PAYMENT_ACTION_REQUIRED: 'Votre paiement nécessite une vérification supplémentaire. Réessayez ou choisissez un autre moyen de paiement.', TRANSACTION_PROCESSING_FAILED: 'Échec du traitement de la transaction', diff --git a/packages/localizations/src/idId.ts b/packages/localizations/src/idId.ts index c067c1be..868007dd 100644 --- a/packages/localizations/src/idId.ts +++ b/packages/localizations/src/idId.ts @@ -278,6 +278,8 @@ export const idId = { DRAFT_ORDER_RETRIEVAL_FAILED: 'Gagal mengambil pesanan', DATA_SOURCES_NOT_FOUND: 'Sumber data tidak ditemukan', TRANSACTION_API_NOT_FOUND: 'Sumber data API transaksi tidak ditemukan', + PAYMENT_STILL_CONFIRMING: + 'Pembayaran sebelumnya untuk pesanan ini masih dikonfirmasi. Tunggu sebentar, atau muat ulang halaman untuk memeriksa statusnya.', PAYMENT_ACTION_REQUIRED: 'Pembayaran Anda memerlukan verifikasi tambahan. Coba lagi atau pilih metode pembayaran lain.', TRANSACTION_PROCESSING_FAILED: 'Gagal memproses transaksi', diff --git a/packages/localizations/src/itIt.ts b/packages/localizations/src/itIt.ts index afe1aa51..ee9c3fb1 100644 --- a/packages/localizations/src/itIt.ts +++ b/packages/localizations/src/itIt.ts @@ -292,6 +292,8 @@ export const itIt = { DRAFT_ORDER_RETRIEVAL_FAILED: "Errore nel recupero dell'ordine", DATA_SOURCES_NOT_FOUND: 'Fonti dati non trovate', TRANSACTION_API_NOT_FOUND: 'Fonte dati API transazioni non trovata', + PAYMENT_STILL_CONFIRMING: + 'Un pagamento precedente per questo ordine è ancora in fase di conferma. Attendi un momento o aggiorna la pagina per verificarne lo stato.', PAYMENT_ACTION_REQUIRED: 'Il pagamento richiede una verifica aggiuntiva. Riprova o scegli un altro metodo di pagamento.', TRANSACTION_PROCESSING_FAILED: "Errore nell'elaborazione della transazione", diff --git a/packages/localizations/src/ptBr.ts b/packages/localizations/src/ptBr.ts index c59ae720..c1773c72 100644 --- a/packages/localizations/src/ptBr.ts +++ b/packages/localizations/src/ptBr.ts @@ -282,6 +282,8 @@ export const ptBr = { DATA_SOURCES_NOT_FOUND: 'Fontes de dados não encontradas', TRANSACTION_API_NOT_FOUND: 'Fonte de dados da API de transação não encontrada', + PAYMENT_STILL_CONFIRMING: + 'Um pagamento anterior deste pedido ainda está sendo confirmado. Aguarde um momento ou atualize a página para verificar o status.', PAYMENT_ACTION_REQUIRED: 'Seu pagamento precisa de uma verificação adicional. Tente novamente ou escolha outra forma de pagamento.', TRANSACTION_PROCESSING_FAILED: 'Falha ao processar transação', diff --git a/packages/localizations/src/qaPs.ts b/packages/localizations/src/qaPs.ts index 961e5c78..52ef3191 100644 --- a/packages/localizations/src/qaPs.ts +++ b/packages/localizations/src/qaPs.ts @@ -286,6 +286,8 @@ export const qaPs = { DRAFT_ORDER_RETRIEVAL_FAILED: '[Fâîlëd ţö rëţrîëvë ördër]', DATA_SOURCES_NOT_FOUND: '[Dâţâ šöürçëš ñöţ föüñd îñ šÿšţëm]', TRANSACTION_API_NOT_FOUND: '[Ţrâñšâçţîöñ ÂÞÎ dâţâ šöürçë ñöţ föüñd]', + PAYMENT_STILL_CONFIRMING: + '[A previous payment for this order is still being confirmed. Please wait a moment, or refresh the page to check its status.]', PAYMENT_ACTION_REQUIRED: '[Your payment needs additional verification. Please try again or choose another payment method.]', TRANSACTION_PROCESSING_FAILED: '[Fâîlëd ţö þröçëšš ţrâñšâçţîöñ]', diff --git a/packages/localizations/src/trTr.ts b/packages/localizations/src/trTr.ts index 1244116c..175d2316 100644 --- a/packages/localizations/src/trTr.ts +++ b/packages/localizations/src/trTr.ts @@ -280,6 +280,8 @@ export const trTr = { DRAFT_ORDER_RETRIEVAL_FAILED: 'Sipariş alınamadı', DATA_SOURCES_NOT_FOUND: 'Veri kaynakları bulunamadı', TRANSACTION_API_NOT_FOUND: 'İşlem API veri kaynağı bulunamadı', + PAYMENT_STILL_CONFIRMING: + 'Bu sipariş için önceki bir ödeme hâlâ onaylanıyor. Lütfen biraz bekleyin veya durumunu kontrol etmek için sayfayı yenileyin.', PAYMENT_ACTION_REQUIRED: 'Ödemeniz için ek doğrulama gerekiyor. Lütfen tekrar deneyin veya başka bir ödeme yöntemi seçin.', TRANSACTION_PROCESSING_FAILED: 'İşlem işlenemedi', diff --git a/packages/localizations/src/viVn.ts b/packages/localizations/src/viVn.ts index 04ed1f20..8e6b2552 100644 --- a/packages/localizations/src/viVn.ts +++ b/packages/localizations/src/viVn.ts @@ -278,6 +278,8 @@ export const viVn = { DRAFT_ORDER_RETRIEVAL_FAILED: 'Không thể truy xuất đơn hàng', DATA_SOURCES_NOT_FOUND: 'Không tìm thấy nguồn dữ liệu', TRANSACTION_API_NOT_FOUND: 'Không tìm thấy nguồn dữ liệu API giao dịch', + PAYMENT_STILL_CONFIRMING: + 'Một khoản thanh toán trước đó cho đơn hàng này vẫn đang được xác nhận. Vui lòng đợi một chút hoặc tải lại trang để kiểm tra trạng thái.', PAYMENT_ACTION_REQUIRED: 'Thanh toán của bạn cần được xác minh thêm. Vui lòng thử lại hoặc chọn phương thức thanh toán khác.', TRANSACTION_PROCESSING_FAILED: 'Không thể xử lý giao dịch', diff --git a/packages/localizations/src/zhCn.ts b/packages/localizations/src/zhCn.ts index 866be391..a0c01351 100644 --- a/packages/localizations/src/zhCn.ts +++ b/packages/localizations/src/zhCn.ts @@ -270,6 +270,8 @@ export const zhCn = { DRAFT_ORDER_RETRIEVAL_FAILED: '获取订单失败', DATA_SOURCES_NOT_FOUND: '数据源未找到', TRANSACTION_API_NOT_FOUND: '交易 API 数据源未找到', + PAYMENT_STILL_CONFIRMING: + '此订单的上一笔付款仍在确认中。请稍候,或刷新页面查看其状态。', PAYMENT_ACTION_REQUIRED: '您的付款需要额外验证。请重试或选择其他付款方式。', TRANSACTION_PROCESSING_FAILED: '处理交易失败', TRANSACTION_CREATION_FAILED: '查找或创建交易失败', diff --git a/packages/localizations/src/zhSg.ts b/packages/localizations/src/zhSg.ts index 4345466b..246b3498 100644 --- a/packages/localizations/src/zhSg.ts +++ b/packages/localizations/src/zhSg.ts @@ -270,6 +270,8 @@ export const zhSg = { DRAFT_ORDER_RETRIEVAL_FAILED: '检索订单失败', DATA_SOURCES_NOT_FOUND: '数据源未找到', TRANSACTION_API_NOT_FOUND: '交易API数据源未找到', + PAYMENT_STILL_CONFIRMING: + '此订单的上一笔付款仍在确认中。请稍候,或刷新页面查看其状态。', PAYMENT_ACTION_REQUIRED: '您的付款需要额外验证。请重试或选择其他付款方式。', TRANSACTION_PROCESSING_FAILED: '处理交易失败', TRANSACTION_CREATION_FAILED: '查找或创建交易失败', 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 868da75a..c14c7c0e 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,6 +9,7 @@ import { PaymentProvider, useConfirmCheckout, } from '@/components/checkout/payment/utils/use-confirm-checkout'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { confirmCheckout } from '@/lib/godaddy/godaddy'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { @@ -349,6 +350,30 @@ describe('Checkout confirm errors', () => { expect(window.location.href).not.toContain('should-not-go-here'); }); + it('blocks every provider while another flow has an unresolved payment', async () => { + const { session, queryClient, user } = renderCheckoutWithConfirmSeam({ + sessionOverrides: offlineSessionOverrides(), + }); + await waitForCheckoutReady(); + // A card 3-D Secure payment whose final confirmation had an unclear outcome. + queryClient.setQueryData(checkoutQueryKeys.unresolvedPayment(session.id), { + flow: 'card', + paymentReference: 'pi_unresolved', + }); + vi.mocked(confirmCheckout).mockClear(); + + await user.click( + await screen.findByRole('button', { name: /confirm seam/i }) + ); + + expect( + await screen.findByText(/still being confirmed/i) + ).toBeInTheDocument(); + expect(confirmCheckout).not.toHaveBeenCalled(); + // Checkout stays usable: nothing is left locked. + expect(screen.getByRole('button', { name: /confirm seam/i })).toBeEnabled(); + }); + it('refreshes after a lost confirmation response and redirects the paid order', async () => { mockWindowLocation(); const successUrl = 'https://merchant.example/success'; 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 e24e8494..2d1bc684 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 @@ -2,9 +2,11 @@ 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, + setCurrentDraftOrder, waitForCheckoutReady, } from './checkout-test-env'; @@ -167,6 +169,44 @@ describe('Checkout paid-order recovery', () => { } ); + it('looks up the status again instead of acting on one cached before the order was paid', async () => { + const successUrl = 'https://merchant.example/success'; + const returnUrl = 'https://merchant.example/cart'; + const { queryClient, session } = renderCheckout({ + sessionOverrides: { successUrl, returnUrl }, + draftOrderOverrides: { statuses: { paymentStatus: 'UNPAID' } }, + }); + await waitForCheckoutReady(); + // Recovery after a lost confirmation response cached UNPAID before the + // payment was recorded. + act(() => { + queryClient.setQueryData(checkoutQueryKeys.orderStatus(session.id), { + checkoutSession: { + id: session.id, + orderStatus: { status: 'DRAFT', paymentStatus: 'UNPAID' }, + }, + }); + }); + // The payment is recorded, so the draft order is no longer returned. + setCurrentDraftOrder( + buildDraftOrder({ statuses: { status: 'OPEN', paymentStatus: 'PAID' } }) + ); + await act(async () => { + await queryClient.refetchQueries({ + queryKey: checkoutQueryKeys.draftOrder(session.id), + }); + }); + + expect(await screen.findByRole('status')).toHaveTextContent( + 'Payment successful' + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(1100); + }); + expect(window.location.href).toBe(successUrl); + expect(getOperations('CheckoutOrderStatus').length).toBeGreaterThan(0); + }); + it('redirects when a refreshed order status becomes paid', async () => { const successUrl = 'https://merchant.example/success'; const { queryClient, session } = renderCheckout({ 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 817d7ef9..f5fe7a58 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 @@ -33,7 +33,10 @@ export function useCheckoutOrderStatus({ enabled }: { enabled: boolean }) { // 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, + // Always look the status up again when it's needed: a status cached before + // the draft order went missing (for example UNPAID, from the recovery after + // a lost confirmation) can't explain why it's missing now. + staleTime: 0, 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. 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 b7c5e588..8fae1a75 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 @@ -23,6 +23,8 @@ export function usePaidOrderRedirect({ const orderStatusQuery = useCheckoutOrderStatus({ enabled: needsOrderStatus, }); + const { isFetching, isPending, isError } = orderStatusQuery; + const showPaidOrder = isCheckoutComplete(orderStatusQuery.data) && !isConfirmingCheckout; @@ -32,7 +34,11 @@ export function usePaidOrderRedirect({ return { showPaidOrder, - // Pending until the lookup settles, including the render that enables it. - isLoadingOrderStatus: needsOrderStatus && orderStatusQuery.isPending, + // Loading until the lookup started when the draft order went missing + // settles, so the missing-order fallback can't act on an older cached + // status (useCheckoutOrderStatus treats every cached status as stale). A + // failed lookup (an API without orderStatus) settles it. + isLoadingOrderStatus: + needsOrderStatus && (isFetching || (isPending && !isError)), }; } diff --git a/packages/react/src/components/checkout/payment/utils/checkout-error-reported.ts b/packages/react/src/components/checkout/payment/utils/checkout-error-reported.ts new file mode 100644 index 00000000..3bbf9b84 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/checkout-error-reported.ts @@ -0,0 +1,16 @@ +/** + * Thrown after the thrower has already set the checkout error codes to show + * (for example MISSING_SHIPPING_INFO), so callers must not replace them. + */ +export class CheckoutErrorAlreadyReported extends Error { + constructor(message: string) { + super(message); + this.name = 'CheckoutErrorAlreadyReported'; + } +} + +export function isCheckoutErrorAlreadyReported( + error: unknown +): error is CheckoutErrorAlreadyReported { + return error instanceof CheckoutErrorAlreadyReported; +} 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 668fe563..ae55ca6b 100644 --- a/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx +++ b/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx @@ -1,28 +1,8 @@ import { Elements, useElements } from '@stripe/react-stripe-js'; -import { - createContext, - type RefObject, - useContext, - useEffect, - useRef, -} from 'react'; +import { useEffect } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useStripePaymentIntent } from '@/components/checkout/payment/utils/use-stripe-payment-intent'; -type PendingStripeIntent = { - sessionId: string | undefined; - id: string; - paymentType?: string; -}; -const StripePaymentContext = - createContext | null>(null); - -export function usePendingStripeIntent() { - const pendingIntent = useContext(StripePaymentContext); - if (!pendingIntent) throw new Error('StripeProvider is required'); - return pendingIntent; -} - function StripeElementsUpdater({ amount = 0 }: { amount?: number }) { const elements = useElements(); @@ -45,19 +25,15 @@ export function StripeProvider({ isExpress?: boolean; }) { const { stripeConfig } = useCheckoutContext(); - // The provider survives the checkout button being replaced with a spinner. - // Card and express providers each own their continuation reference. - const pendingIntent = useRef(null); - return ( - - {stripeConfig?.publishableKey?.trim() ? ( - - {children} - - ) : ( - children - )} - + // An intent awaiting 3-D Secure lives in the session-scoped unresolved + // payment (unresolved-payment.ts), so it survives remounts and is visible to + // every payment flow. + return stripeConfig?.publishableKey?.trim() ? ( + + {children} + + ) : ( + <>{children} ); } diff --git a/packages/react/src/components/checkout/payment/utils/unresolved-payment.ts b/packages/react/src/components/checkout/payment/utils/unresolved-payment.ts new file mode 100644 index 00000000..760cb416 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/unresolved-payment.ts @@ -0,0 +1,121 @@ +import { type QueryClient, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; + +export type PaymentFlow = 'card' | 'express'; + +/** + * A provider payment that may already have moved money but isn't confirmed on + * the order yet (today: a Stripe intent after a 3-D Secure challenge). + * + * Only the flow that created it resumes it, because card and express checkouts + * send different taxes, shipping and totals. Every other confirmation for the + * session is blocked until it resolves, whatever the payment provider, so a + * second payment can't be taken while the first one may have succeeded. + */ +export type UnresolvedPayment = { + flow: PaymentFlow; + paymentReference: string; + paymentType?: string; +}; + +export const PAYMENT_STILL_CONFIRMING = 'PAYMENT_STILL_CONFIRMING'; + +/** + * A GraphQLErrorWithCodes, so every payment button's existing error handling + * shows the code without knowing about it. Nothing was sent to the server. + */ +export class UnresolvedPaymentError extends GraphQLErrorWithCodes { + constructor() { + super([ + { + code: PAYMENT_STILL_CONFIRMING, + message: + 'A previous payment for this checkout is still being confirmed', + }, + ]); + this.name = 'UnresolvedPaymentError'; + } +} + +export function isUnresolvedPaymentError( + error: unknown +): error is UnresolvedPaymentError { + return error instanceof UnresolvedPaymentError; +} + +export function getUnresolvedPayment( + queryClient: QueryClient, + sessionId: string | undefined | null +): UnresolvedPayment | undefined { + if (!sessionId) return undefined; + return ( + queryClient.getQueryData( + checkoutQueryKeys.unresolvedPayment(sessionId) + ) ?? undefined + ); +} + +/** + * Throws unless the confirmation is the owning flow resuming its own payment. + * Called at the start of every confirmation, before any request is sent. + */ +export function assertNoUnresolvedPayment( + queryClient: QueryClient, + sessionId: string | undefined | null, + attempt: { flow: PaymentFlow | 'authorize'; paymentToken?: string | null } +) { + const unresolved = getUnresolvedPayment(queryClient, sessionId); + if (!unresolved) return; + if ( + unresolved.flow === attempt.flow && + unresolved.paymentReference === attempt.paymentToken + ) { + return; + } + throw new UnresolvedPaymentError(); +} + +/** Session-scoped access shared by every payment flow under one QueryClient. */ +export function useUnresolvedPayment() { + const queryClient = useQueryClient(); + const { session } = useCheckoutContext(); + const sessionId = session?.id; + + const get = useCallback( + () => getUnresolvedPayment(queryClient, sessionId), + [queryClient, sessionId] + ); + + const set = useCallback( + (payment: UnresolvedPayment) => { + if (!sessionId) return; + const queryKey = checkoutQueryKeys.unresolvedPayment(sessionId); + // Not fetched, only written: keep it until it's cleared, even with no observer. + queryClient.setQueryDefaults(queryKey, { + gcTime: Number.POSITIVE_INFINITY, + staleTime: Number.POSITIVE_INFINITY, + }); + queryClient.setQueryData(queryKey, payment); + }, + [queryClient, sessionId] + ); + + const clear = useCallback( + (flow: PaymentFlow) => { + if (!sessionId) return; + const queryKey = checkoutQueryKeys.unresolvedPayment(sessionId); + if ( + queryClient.getQueryData(queryKey)?.flow !== flow + ) { + return; + } + queryClient.setQueryData(queryKey, null); + }, + [queryClient, sessionId] + ); + + return { get, set, clear }; +} diff --git a/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.test.tsx b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.test.tsx index 3f7f1adb..f6875270 100644 --- a/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.test.tsx @@ -9,6 +9,7 @@ import { import { DraftOrderSyncProvider } from '@/components/checkout/order/draft-order-sync-provider'; import { useAuthorizeCheckout } from '@/components/checkout/payment/utils/use-authorize-checkout'; import { PaymentProvider } from '@/components/checkout/payment/utils/use-confirm-checkout'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { GoDaddyProvider } from '@/godaddy-provider'; import { PaymentMethodType } from '@/types'; import { @@ -22,14 +23,22 @@ import { function wrapper({ enableTips = false, tipAmount, + unresolvedPayment, }: { enableTips?: boolean; tipAmount?: number; + unresolvedPayment?: { flow: 'card' | 'express'; paymentReference: string }; } = {}) { const session = buildCheckoutSession({ enableTips }); const draftOrder = buildDraftOrder(); mockGodaddyApi({ session, draftOrder }); const queryClient = createTestQueryClient(); + if (unresolvedPayment) { + queryClient.setQueryData( + checkoutQueryKeys.unresolvedPayment(session.id), + unresolvedPayment + ); + } return function Wrapper({ children }: { children: React.ReactNode }) { const methods = useForm({ @@ -72,6 +81,23 @@ async function authorizedInput() { } describe('useAuthorizeCheckout', () => { + it('authorizes nothing while another flow has an unresolved payment', async () => { + const { result } = renderHook(() => useAuthorizeCheckout(), { + wrapper: wrapper({ + unresolvedPayment: { flow: 'card', paymentReference: 'pi_unresolved' }, + }), + }); + + await expect( + result.current.mutateAsync({ + paymentType: PaymentMethodType.CCAVENUE, + paymentProvider: PaymentProvider.CCAVENUE, + paymentToken: '', + }) + ).rejects.toMatchObject({ codes: ['PAYMENT_STILL_CONFIRMING'] }); + expect(getOperations('AuthorizeCheckoutSession')).toHaveLength(0); + }); + it('authorizes for the tip-inclusive amount when tips are enabled', async () => { const { result } = renderHook(() => useAuthorizeCheckout(), { wrapper: wrapper({ enableTips: true, tipAmount: 500 }), diff --git a/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts index 41755a03..d89d5c64 100644 --- a/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts @@ -1,4 +1,4 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; @@ -10,6 +10,10 @@ import { import { useGoDaddyContext } from '@/godaddy-provider'; import { authorizeCheckoutSession } from '@/lib/godaddy/godaddy'; import type { AuthorizeCheckoutSessionInput } from '@/types'; +import { + assertNoUnresolvedPayment, + isUnresolvedPaymentError, +} from './unresolved-payment'; export function useAuthorizeCheckout() { const { session, jwt } = useCheckoutContext(); @@ -17,9 +21,17 @@ export function useAuthorizeCheckout() { const form = useFormContext(); const { data: totals } = useDraftOrderTotals(); const flushCheckoutSync = useFlushCheckoutSync(); + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (input: AuthorizeCheckoutSessionInput['input']) => { + // Redirect providers (CCAvenue) take the payment off site after this, and + // PayPal and MercadoPago collect approval, so block before any of it while + // another flow's payment may still have succeeded. Authorize never resumes + // an unresolved payment, so no flow or token can match. + assertNoUnresolvedPayment(queryClient, session?.id, { + flow: 'authorize', + }); await flushCheckoutSync(); // The form is the single source of truth for the tip, deliberately @@ -53,6 +65,7 @@ export function useAuthorizeCheckout() { }; }, onError: (error: unknown) => { + if (isUnresolvedPaymentError(error)) return; const translate = (code: string) => t.apiErrors?.[code as keyof typeof t.apiErrors]; 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 38320ef1..c8e87dcc 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 @@ -1,4 +1,4 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useRef } from 'react'; import { useFormContext } from 'react-hook-form'; import { @@ -25,7 +25,13 @@ import { track, } from '@/tracking/track'; import type { ConfirmCheckoutMutationInput } from '@/types'; +import { CheckoutErrorAlreadyReported } from './checkout-error-reported'; import { getStripeNextAction } from './stripe-next-action'; +import { + assertNoUnresolvedPayment, + isUnresolvedPaymentError, + PAYMENT_STILL_CONFIRMING, +} from './unresolved-payment'; import { useConfirmCheckoutRecovery } from './use-confirm-checkout-recovery'; export class CheckoutConfirmationBlockedError extends Error { @@ -103,6 +109,7 @@ export function useConfirmCheckout() { const { data: order } = useDraftOrder(); const flushCheckoutSync = useFlushCheckoutSync(); const isPendingRef = useRef(false); + const queryClient = useQueryClient(); const confirmWithRecovery = useConfirmCheckoutRecovery(); return useMutation({ @@ -123,6 +130,12 @@ export function useConfirmCheckout() { 'Checkout confirmation is already in progress' ); } + // Card payments resume as flow 'card'. Any other provider, or a wallet + // confirmed here, is blocked while another flow's payment is unresolved. + assertNoUnresolvedPayment(queryClient, session.id, { + flow: input.isExpress ? 'express' : 'card', + paymentToken: input.paymentToken, + }); isPendingRef.current = true; @@ -157,7 +170,7 @@ export function useConfirmCheckout() { (!hasShippingLines || hasLineItemsMissingShippingFulfillment) ) { setCheckoutErrors(['MISSING_SHIPPING_INFO']); - throw new Error('MISSING_SHIPPING_INFO'); + throw new CheckoutErrorAlreadyReported('MISSING_SHIPPING_INFO'); } const pickupLocationId = form.getValues('pickupLocationId'); @@ -288,6 +301,12 @@ export function useConfirmCheckout() { }, onError: (error: unknown, data) => { if (isCheckoutConfirmationBlockedError(error)) return; + // Nothing was sent: unlock, and say why for every payment provider. + if (isUnresolvedPaymentError(error)) { + setCheckoutErrors([PAYMENT_STILL_CONFIRMING]); + setIsConfirmingCheckout(false); + return; + } if ( data?.paymentProvider === PaymentProvider.STRIPE && 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 2d0176de..57fa9767 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,12 @@ import { } from '@/tracking/track'; import type { ConfirmCheckoutMutationInput } from '@/types'; import { getStripeNextAction } from './stripe-next-action'; +import { + assertNoUnresolvedPayment, + getUnresolvedPayment, + isUnresolvedPaymentError, + PAYMENT_STILL_CONFIRMING, +} from './unresolved-payment'; import { useConfirmCheckoutRecovery } from './use-confirm-checkout-recovery'; export function useConfirmExpressCheckout() { @@ -32,10 +38,6 @@ export function useConfirmExpressCheckout() { const { apiHost } = useGoDaddyContext(); const queryClient = useQueryClient(); const isPendingRef = useRef(false); - const pendingActionRef = useRef<{ - sessionId: string; - paymentReference: string; - } | null>(null); const confirmWithRecovery = useConfirmCheckoutRecovery(); return useMutation({ @@ -51,10 +53,13 @@ export function useConfirmExpressCheckout() { if (!input?.paymentType) { throw new Error('Express checkout payment type is unavailable'); } + // Read from the session-scoped unresolved payment rather than this hook + // instance, so a remounted express form still recognizes its own resume. + const unresolved = getUnresolvedPayment(queryClient, session.id); const isContinuation = input.paymentProvider === PaymentProvider.STRIPE && - pendingActionRef.current?.sessionId === session.id && - pendingActionRef.current.paymentReference === input.paymentToken; + unresolved?.flow === 'express' && + unresolved.paymentReference === input.paymentToken; if (isConfirmingCheckout && !isContinuation) { throw new CheckoutConfirmationBlockedError( 'Checkout confirmation is already in progress' @@ -74,6 +79,10 @@ export function useConfirmExpressCheckout() { 'Express checkout confirmation is already in progress' ); } + assertNoUnresolvedPayment(queryClient, session.id, { + flow: 'express', + paymentToken: input.paymentToken, + }); isPendingRef.current = true; @@ -115,7 +124,6 @@ export function useConfirmExpressCheckout() { }, onSuccess: (data, input) => { if (!data) return; - pendingActionRef.current = null; let completedEventId: TrackingEventId | null = null; switch (input.paymentType) { case 'apple_pay': @@ -160,20 +168,19 @@ export function useConfirmExpressCheckout() { }, onError: (error: unknown, data) => { if (isCheckoutConfirmationBlockedError(error)) return; + if (isUnresolvedPaymentError(error)) { + setCheckoutErrors([PAYMENT_STILL_CONFIRMING]); + setIsConfirmingCheckout(false); + return; + } - const nextAction = getStripeNextAction(error); + // useStripeCheckout records the challenge and keeps checkout locked. if ( data?.paymentProvider === PaymentProvider.STRIPE && - nextAction && - session?.id + getStripeNextAction(error) ) { - pendingActionRef.current = { - sessionId: session.id, - paymentReference: nextAction.paymentReference, - }; return; } - pendingActionRef.current = null; track({ eventId: eventIds.checkoutError, diff --git a/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts index 634d5de2..134f8625 100644 --- a/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts +++ b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts @@ -9,6 +9,9 @@ import { const DEFAULT_TIMEOUT_MS = 10_000; const POLL_INTERVAL_MS = 50; + +import { CheckoutErrorAlreadyReported } from './checkout-error-reported'; + const CHECKOUT_SYNC_ERROR = 'DRAFT_ORDER_UPDATE_FAILED'; interface FlushCheckoutSyncOptions { @@ -104,7 +107,9 @@ export function useFlushCheckoutSync() { return result; } catch (error) { setCheckoutErrors([CHECKOUT_SYNC_ERROR]); - throw error; + throw new CheckoutErrorAlreadyReported( + error instanceof Error ? error.message : CHECKOUT_SYNC_ERROR + ); } }, [flushDraftOrderSync, queryClient, session, setCheckoutErrors] 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 b9f4b73f..bd5d0a7e 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,11 +1,14 @@ import type { StripeExpressCheckoutElementConfirmEvent } from '@stripe/stripe-js'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 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'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { eventIds } from '@/tracking/events'; import type { DraftOrder } from '@/types'; +import { CheckoutErrorAlreadyReported } from './checkout-error-reported'; import { StripeProvider } from './stripe-provider'; import { CheckoutConfirmationBlockedError } from './use-confirm-checkout'; import { useStripeCheckout } from './use-stripe-checkout'; @@ -79,18 +82,26 @@ vi.mock( }) ); +// The unresolved payment lives in the QueryClient, so each test gets its own. +let queryClient = new QueryClient(); +beforeEach(() => { + queryClient = new QueryClient(); +}); + function Wrapper({ children }: { children: React.ReactNode }) { return ( - - {children} - + + + {children} + + ); } @@ -286,6 +297,82 @@ describe('useStripeCheckout payment request resolution', () => { mocks.confirmExpress.mockResolvedValue(undefined); }); + it.each(['card', 'express'] as const)( + "keeps the confirm hook's own error code on a %s non-GraphQL failure", + async mode => { + // useConfirmCheckout sets e.g. MISSING_SHIPPING_INFO, then throws. + const confirm = mode === 'card' ? mocks.confirm : mocks.confirmExpress; + confirm.mockRejectedValueOnce( + new CheckoutErrorAlreadyReported('MISSING_SHIPPING_INFO') + ); + const { result } = renderHook(() => useStripeCheckout({ mode }), { + wrapper: Wrapper, + }); + + await act(async () => { + await result.current + .handleSubmit( + mode === 'express' + ? { + event: { + expressPaymentType: 'apple_pay', + } as StripeExpressCheckoutElementConfirmEvent, + } + : undefined + ) + .catch(() => undefined); + }); + + expect(mocks.setCheckoutErrors).not.toHaveBeenCalled(); + expect(mocks.setIsConfirmingCheckout).toHaveBeenCalledWith(false); + } + ); + + it.each([ + ['a bodyless 502 on the final confirmation', 'confirm'], + ['a Stripe.js rejection', 'sdk'], + ] as const)('still reports %s after 3-D Secure', async (_name, failure) => { + mocks.confirm.mockRejectedValueOnce(actionRequiredError()); + if (failure === 'confirm') { + mocks.confirm.mockRejectedValueOnce(new Error('Bad Gateway')); + } else { + mocks.handleNextAction.mockRejectedValueOnce(new Error('network')); + } + const { result } = renderHook(() => useStripeCheckout({ mode: 'card' }), { + wrapper: Wrapper, + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(mocks.setCheckoutErrors).toHaveBeenCalledWith([ + 'TRANSACTION_PROCESSING_FAILED', + ]); + expect(mocks.setIsConfirmingCheckout).toHaveBeenCalledWith(false); + }); + + it('blocks a card payment while an express payment is unresolved', async () => { + queryClient.setQueryData( + checkoutQueryKeys.unresolvedPayment(mocks.sessionId), + { flow: 'express', paymentReference: 'pi-express' } + ); + const { result } = renderHook(() => useStripeCheckout({ mode: 'card' }), { + wrapper: Wrapper, + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + // The card flow creates its own payment method and never resumes the + // express intent; the confirm hook (mocked here) is what blocks it. + expect(mocks.confirm).toHaveBeenCalledWith( + expect.objectContaining({ paymentToken: 'stripe-payment-method' }) + ); + expect(mocks.handleNextAction).not.toHaveBeenCalled(); + }); + it('tokenizes card billing from the flushed latest order before confirmation', async () => { const { result } = renderHook(() => useStripeCheckout({ mode: 'card' }), { wrapper: Wrapper, 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 bd044272..3b6b2e90 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 @@ -26,13 +26,24 @@ import type { ShippingMethod, } from '@/types'; import { PaymentMethodType } from '@/types'; +import { isCheckoutErrorAlreadyReported } from './checkout-error-reported'; import { getStripeNextAction, stripeCheckoutErrorCode, } from './stripe-next-action'; -import { usePendingStripeIntent } from './stripe-provider'; +import { + isUnresolvedPaymentError, + PAYMENT_STILL_CONFIRMING, + useUnresolvedPayment, +} from './unresolved-payment'; -function confirmationErrorCodes(error: unknown): string[] { +// Codes to show for a failed Stripe confirmation, or undefined to leave codes +// already set by the confirm hook or checkout sync (e.g. MISSING_SHIPPING_INFO). +// Every other failure, including a bodyless 502 or a Stripe.js rejection, +// still shows TRANSACTION_PROCESSING_FAILED. +function confirmationErrorCodes(error: unknown): string[] | undefined { + if (isCheckoutErrorAlreadyReported(error)) return undefined; + if (isUnresolvedPaymentError(error)) return [PAYMENT_STILL_CONFIRMING]; if ( error instanceof GraphQLErrorWithCodes && error.codes.length && @@ -92,14 +103,13 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { const elements = useElements(); const confirmCheckout = useConfirmCheckout(); const confirmExpressCheckout = useConfirmExpressCheckout(); - const { session, setCheckoutErrors, setIsConfirmingCheckout } = - useCheckoutContext(); + const { setCheckoutErrors, setIsConfirmingCheckout } = useCheckoutContext(); const { stripePaymentMethodParams, buildPaymentRequestsFromOrder } = useBuildPaymentRequest(); const flushCheckoutSync = useFlushCheckoutSync(); const [isProcessingPayment, setIsProcessingPayment] = useState(false); const isSubmittingRef = useRef(false); - const pendingIntent = usePendingStripeIntent(); + const unresolvedPayment = useUnresolvedPayment(); const handleSubmit = useCallback( async ( @@ -114,11 +124,13 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { return; } - if (pendingIntent.current?.sessionId !== session?.id) { - pendingIntent.current = null; - } + // Only this flow may resume its own intent: card and express send + // different taxes, shipping and totals. Another flow's unresolved + // payment blocks this one at the confirm hook instead. + const ownPending = unresolvedPayment.get(); + const pending = ownPending?.flow === mode ? ownPending : undefined; + const clearPending = () => unresolvedPayment.clear(mode); - // Both card and express payments resume the same intent after authentication. const confirmWithNextAction = async ( paymentToken: string, paymentType: string, @@ -139,25 +151,25 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { detail.extensions?.transactionStatus === 'FAILED' ) ) { - pendingIntent.current = null; + clearPending(); } throw error; } }; try { await confirmPayment(paymentToken); - pendingIntent.current = null; + clearPending(); } 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, + unresolvedPayment.set({ + flow: mode, + paymentReference: nextAction.paymentReference, paymentType, - }; + }); track({ eventId: eventIds.paymentChallengeStarted, type: TrackingEventType.EVENT, @@ -189,7 +201,7 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { intent.status ) ) { - pendingIntent.current = null; + clearPending(); } throw new GraphQLErrorWithCodes([ { @@ -202,7 +214,7 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { challengeSucceeded = true; await confirmPayment(actionResult.paymentIntent.id); - pendingIntent.current = null; + clearPending(); } finally { track({ eventId: eventIds.paymentChallengeCompleted, @@ -214,7 +226,7 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { }; if (mode === 'card') { - let paymentToken = pendingIntent.current?.id; + let paymentToken = pending?.paymentReference; if (!paymentToken) { const cardElement = elements.getElement(CardElement); @@ -264,7 +276,8 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { ); } catch (error) { if (isCheckoutConfirmationBlockedError(error)) return; - setCheckoutErrors(confirmationErrorCodes(error)); + const codes = confirmationErrorCodes(error); + if (codes) setCheckoutErrors(codes); setIsConfirmingCheckout(false); } } else { @@ -273,8 +286,8 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { } if (mode === 'express') { - let paymentToken = pendingIntent.current?.id; - let paymentType = pendingIntent.current?.paymentType; + let paymentToken = pending?.paymentReference; + let paymentType = pending?.paymentType; if (!paymentToken) { const { error, paymentMethod } = await stripe.createPaymentMethod({ elements, @@ -415,7 +428,8 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { } } catch (err: unknown) { if (isCheckoutConfirmationBlockedError(err)) throw err; - setCheckoutErrors(confirmationErrorCodes(err)); + const codes = confirmationErrorCodes(err); + if (codes) setCheckoutErrors(codes); setIsConfirmingCheckout(false); throw err; // Re-throw so caller can handle } @@ -432,8 +446,7 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { }, [ mode, - pendingIntent, - session?.id, + unresolvedPayment, stripe, elements, confirmCheckout.mutateAsync, diff --git a/packages/react/src/components/checkout/utils/query-keys.ts b/packages/react/src/components/checkout/utils/query-keys.ts index 07d854fd..0a9bfe04 100644 --- a/packages/react/src/components/checkout/utils/query-keys.ts +++ b/packages/react/src/components/checkout/utils/query-keys.ts @@ -34,4 +34,6 @@ export const checkoutQueryKeys = { ['draft-order-shipping-methods', { sessionId }] as const, orderStatus: (sessionId?: string | null) => ['checkout-order-status', { sessionId }] as const, + unresolvedPayment: (sessionId?: string | null) => + ['checkout-unresolved-payment', { sessionId }] as const, }; From 59f376f1f027a7a93b632b5af606988bf12496d6 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Mon, 26 Oct 2026 12:55:44 -0500 Subject: [PATCH 17/18] Ignore local Claude Code state Claude Code keeps worktrees and settings under .claude/. A worktree there is a nested repository, so `git add` recorded it as a submodule pointer in an earlier commit on this branch. Ignore the directory. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index be44de1d..34d0665f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ dist .DS_Store .env .env.local + +# Claude Code local state (worktrees, settings) +.claude/ From bd4456eedd4455d2e0eac367d01964d69c1347ac Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Mon, 26 Oct 2026 13:41:30 -0500 Subject: [PATCH 18/18] Don't suggest a refresh while a payment is still confirming The unresolved payment guard lives in memory, so a reload clears it and lets another payment method start. The PAYMENT_STILL_CONFIRMING message told shoppers to refresh the page, which led straight into that gap. Ask them to wait instead. Also move an import in use-flush-checkout-sync.ts up with the others. Co-Authored-By: Claude Opus 5.5 --- packages/localizations/src/deDe.ts | 2 +- packages/localizations/src/enAu.ts | 2 +- packages/localizations/src/enIe.ts | 2 +- packages/localizations/src/enUs.ts | 2 +- packages/localizations/src/esAr.ts | 2 +- packages/localizations/src/esCl.ts | 2 +- packages/localizations/src/esCo.ts | 2 +- packages/localizations/src/esEs.ts | 2 +- packages/localizations/src/esMx.ts | 2 +- packages/localizations/src/esPe.ts | 2 +- packages/localizations/src/esUs.ts | 2 +- packages/localizations/src/frCa.ts | 2 +- packages/localizations/src/frFr.ts | 2 +- packages/localizations/src/idId.ts | 2 +- packages/localizations/src/itIt.ts | 2 +- packages/localizations/src/ptBr.ts | 2 +- packages/localizations/src/qaPs.ts | 2 +- packages/localizations/src/trTr.ts | 2 +- packages/localizations/src/viVn.ts | 2 +- packages/localizations/src/zhCn.ts | 2 +- packages/localizations/src/zhSg.ts | 2 +- .../checkout/payment/utils/use-flush-checkout-sync.ts | 4 +--- 22 files changed, 22 insertions(+), 24 deletions(-) diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts index 6143f1c0..4b9cb643 100644 --- a/packages/localizations/src/deDe.ts +++ b/packages/localizations/src/deDe.ts @@ -292,7 +292,7 @@ export const deDe = { DATA_SOURCES_NOT_FOUND: 'Datenquellen nicht gefunden', TRANSACTION_API_NOT_FOUND: 'Transaktions-API-Datenquelle nicht gefunden', PAYMENT_STILL_CONFIRMING: - 'Eine vorherige Zahlung für diese Bestellung wird noch bestätigt. Warte einen Moment oder lade die Seite neu, um den Status zu prüfen.', + 'Eine vorherige Zahlung für diese Bestellung wird noch bestätigt. Warte einen Moment, bevor du es erneut versuchst.', PAYMENT_ACTION_REQUIRED: 'Deine Zahlung erfordert eine zusätzliche Bestätigung. Versuche es erneut oder wähle eine andere Zahlungsmethode.', TRANSACTION_PROCESSING_FAILED: diff --git a/packages/localizations/src/enAu.ts b/packages/localizations/src/enAu.ts index c844830e..552ead95 100644 --- a/packages/localizations/src/enAu.ts +++ b/packages/localizations/src/enAu.ts @@ -279,7 +279,7 @@ export const enAu = { DATA_SOURCES_NOT_FOUND: 'Data sources not found', TRANSACTION_API_NOT_FOUND: 'Transaction API data source not found', PAYMENT_STILL_CONFIRMING: - 'A previous payment for this order is still being confirmed. Please wait a moment, or refresh the page to check its status.', + 'A previous payment for this order is still being confirmed. Please wait a moment before trying again.', PAYMENT_ACTION_REQUIRED: 'Your payment needs additional verification. Please try again or choose another payment method.', TRANSACTION_PROCESSING_FAILED: 'Failed to process transaction', diff --git a/packages/localizations/src/enIe.ts b/packages/localizations/src/enIe.ts index 06e1aee3..62beb2b2 100644 --- a/packages/localizations/src/enIe.ts +++ b/packages/localizations/src/enIe.ts @@ -279,7 +279,7 @@ export const enIe = { DATA_SOURCES_NOT_FOUND: 'Data sources not found', TRANSACTION_API_NOT_FOUND: 'Transaction API data source not found', PAYMENT_STILL_CONFIRMING: - 'A previous payment for this order is still being confirmed. Please wait a moment, or refresh the page to check its status.', + 'A previous payment for this order is still being confirmed. Please wait a moment before trying again.', PAYMENT_ACTION_REQUIRED: 'Your payment needs additional verification. Please try again or choose another payment method.', TRANSACTION_PROCESSING_FAILED: 'Failed to process transaction', diff --git a/packages/localizations/src/enUs.ts b/packages/localizations/src/enUs.ts index ddc661bd..1547c5ea 100644 --- a/packages/localizations/src/enUs.ts +++ b/packages/localizations/src/enUs.ts @@ -279,7 +279,7 @@ export const enUs = { DATA_SOURCES_NOT_FOUND: 'Data sources not found', TRANSACTION_API_NOT_FOUND: 'Transaction API data source not found', PAYMENT_STILL_CONFIRMING: - 'A previous payment for this order is still being confirmed. Please wait a moment, or refresh the page to check its status.', + 'A previous payment for this order is still being confirmed. Please wait a moment before trying again.', PAYMENT_ACTION_REQUIRED: 'Your payment needs additional verification. Please try again or choose another payment method.', TRANSACTION_PROCESSING_FAILED: 'Failed to process transaction', diff --git a/packages/localizations/src/esAr.ts b/packages/localizations/src/esAr.ts index 4d0b2ab0..caf08ffd 100644 --- a/packages/localizations/src/esAr.ts +++ b/packages/localizations/src/esAr.ts @@ -285,7 +285,7 @@ export const esAr = { TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacciones no encontrada', PAYMENT_STILL_CONFIRMING: - 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento o actualiza la página para ver su estado.', + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento antes de volver a intentarlo.', PAYMENT_ACTION_REQUIRED: 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', diff --git a/packages/localizations/src/esCl.ts b/packages/localizations/src/esCl.ts index cf154f8f..6a7b9665 100644 --- a/packages/localizations/src/esCl.ts +++ b/packages/localizations/src/esCl.ts @@ -287,7 +287,7 @@ export const esCl = { TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacción no encontrada', PAYMENT_STILL_CONFIRMING: - 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento o actualiza la página para ver su estado.', + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento antes de volver a intentarlo.', PAYMENT_ACTION_REQUIRED: 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', diff --git a/packages/localizations/src/esCo.ts b/packages/localizations/src/esCo.ts index 1c117a00..bb6134a8 100644 --- a/packages/localizations/src/esCo.ts +++ b/packages/localizations/src/esCo.ts @@ -285,7 +285,7 @@ export const esCo = { TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacciones no encontrada', PAYMENT_STILL_CONFIRMING: - 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento o actualiza la página para ver su estado.', + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento antes de volver a intentarlo.', PAYMENT_ACTION_REQUIRED: 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', diff --git a/packages/localizations/src/esEs.ts b/packages/localizations/src/esEs.ts index 51265625..54f659a2 100644 --- a/packages/localizations/src/esEs.ts +++ b/packages/localizations/src/esEs.ts @@ -288,7 +288,7 @@ export const esEs = { TRANSACTION_API_NOT_FOUND: 'Fuente de datos de la API de transacciones no encontrada', PAYMENT_STILL_CONFIRMING: - 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento o actualiza la página para ver su estado.', + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento antes de volver a intentarlo.', PAYMENT_ACTION_REQUIRED: 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'No se pudo procesar la transacción', diff --git a/packages/localizations/src/esMx.ts b/packages/localizations/src/esMx.ts index ada38afc..1fb97960 100644 --- a/packages/localizations/src/esMx.ts +++ b/packages/localizations/src/esMx.ts @@ -285,7 +285,7 @@ export const esMx = { TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacciones no encontrada', PAYMENT_STILL_CONFIRMING: - 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento o actualiza la página para ver su estado.', + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento antes de volver a intentarlo.', PAYMENT_ACTION_REQUIRED: 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', diff --git a/packages/localizations/src/esPe.ts b/packages/localizations/src/esPe.ts index f6cd4b47..95c6442d 100644 --- a/packages/localizations/src/esPe.ts +++ b/packages/localizations/src/esPe.ts @@ -285,7 +285,7 @@ export const esPe = { TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacciones no encontrada', PAYMENT_STILL_CONFIRMING: - 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento o actualiza la página para ver su estado.', + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento antes de volver a intentarlo.', PAYMENT_ACTION_REQUIRED: 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', diff --git a/packages/localizations/src/esUs.ts b/packages/localizations/src/esUs.ts index fcca79ff..4d96df1b 100644 --- a/packages/localizations/src/esUs.ts +++ b/packages/localizations/src/esUs.ts @@ -285,7 +285,7 @@ export const esUs = { TRANSACTION_API_NOT_FOUND: 'Fuente de datos de Transaction API no encontrada', PAYMENT_STILL_CONFIRMING: - 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento o actualiza la página para ver su estado.', + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento antes de volver a intentarlo.', PAYMENT_ACTION_REQUIRED: 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', diff --git a/packages/localizations/src/frCa.ts b/packages/localizations/src/frCa.ts index 58ac89e7..9dd386f8 100644 --- a/packages/localizations/src/frCa.ts +++ b/packages/localizations/src/frCa.ts @@ -294,7 +294,7 @@ export const frCa = { TRANSACTION_API_NOT_FOUND: 'Source de données API de transaction introuvable', PAYMENT_STILL_CONFIRMING: - 'Un paiement précédent pour cette commande est toujours en cours de confirmation. Patientez un instant ou actualisez la page pour vérifier son état.', + 'Un paiement précédent pour cette commande est toujours en cours de confirmation. Patientez un instant avant de réessayer.', PAYMENT_ACTION_REQUIRED: 'Votre paiement nécessite une vérification supplémentaire. Réessayez ou choisissez un autre moyen de paiement.', TRANSACTION_PROCESSING_FAILED: 'Échec du traitement de la transaction', diff --git a/packages/localizations/src/frFr.ts b/packages/localizations/src/frFr.ts index 0d55129b..a78091bc 100644 --- a/packages/localizations/src/frFr.ts +++ b/packages/localizations/src/frFr.ts @@ -294,7 +294,7 @@ export const frFr = { TRANSACTION_API_NOT_FOUND: "Source de données de l'API de transaction non trouvée", PAYMENT_STILL_CONFIRMING: - 'Un paiement précédent pour cette commande est toujours en cours de confirmation. Patientez un instant ou actualisez la page pour vérifier son état.', + 'Un paiement précédent pour cette commande est toujours en cours de confirmation. Patientez un instant avant de réessayer.', PAYMENT_ACTION_REQUIRED: 'Votre paiement nécessite une vérification supplémentaire. Réessayez ou choisissez un autre moyen de paiement.', TRANSACTION_PROCESSING_FAILED: 'Échec du traitement de la transaction', diff --git a/packages/localizations/src/idId.ts b/packages/localizations/src/idId.ts index 868007dd..e2b8fb17 100644 --- a/packages/localizations/src/idId.ts +++ b/packages/localizations/src/idId.ts @@ -279,7 +279,7 @@ export const idId = { DATA_SOURCES_NOT_FOUND: 'Sumber data tidak ditemukan', TRANSACTION_API_NOT_FOUND: 'Sumber data API transaksi tidak ditemukan', PAYMENT_STILL_CONFIRMING: - 'Pembayaran sebelumnya untuk pesanan ini masih dikonfirmasi. Tunggu sebentar, atau muat ulang halaman untuk memeriksa statusnya.', + 'Pembayaran sebelumnya untuk pesanan ini masih dikonfirmasi. Tunggu sebentar sebelum mencoba lagi.', PAYMENT_ACTION_REQUIRED: 'Pembayaran Anda memerlukan verifikasi tambahan. Coba lagi atau pilih metode pembayaran lain.', TRANSACTION_PROCESSING_FAILED: 'Gagal memproses transaksi', diff --git a/packages/localizations/src/itIt.ts b/packages/localizations/src/itIt.ts index ee9c3fb1..4622259a 100644 --- a/packages/localizations/src/itIt.ts +++ b/packages/localizations/src/itIt.ts @@ -293,7 +293,7 @@ export const itIt = { DATA_SOURCES_NOT_FOUND: 'Fonti dati non trovate', TRANSACTION_API_NOT_FOUND: 'Fonte dati API transazioni non trovata', PAYMENT_STILL_CONFIRMING: - 'Un pagamento precedente per questo ordine è ancora in fase di conferma. Attendi un momento o aggiorna la pagina per verificarne lo stato.', + 'Un pagamento precedente per questo ordine è ancora in fase di conferma. Attendi un momento prima di riprovare.', PAYMENT_ACTION_REQUIRED: 'Il pagamento richiede una verifica aggiuntiva. Riprova o scegli un altro metodo di pagamento.', TRANSACTION_PROCESSING_FAILED: "Errore nell'elaborazione della transazione", diff --git a/packages/localizations/src/ptBr.ts b/packages/localizations/src/ptBr.ts index c1773c72..9da4c139 100644 --- a/packages/localizations/src/ptBr.ts +++ b/packages/localizations/src/ptBr.ts @@ -283,7 +283,7 @@ export const ptBr = { TRANSACTION_API_NOT_FOUND: 'Fonte de dados da API de transação não encontrada', PAYMENT_STILL_CONFIRMING: - 'Um pagamento anterior deste pedido ainda está sendo confirmado. Aguarde um momento ou atualize a página para verificar o status.', + 'Um pagamento anterior deste pedido ainda está sendo confirmado. Aguarde um momento antes de tentar novamente.', PAYMENT_ACTION_REQUIRED: 'Seu pagamento precisa de uma verificação adicional. Tente novamente ou escolha outra forma de pagamento.', TRANSACTION_PROCESSING_FAILED: 'Falha ao processar transação', diff --git a/packages/localizations/src/qaPs.ts b/packages/localizations/src/qaPs.ts index 52ef3191..1c1852d1 100644 --- a/packages/localizations/src/qaPs.ts +++ b/packages/localizations/src/qaPs.ts @@ -287,7 +287,7 @@ export const qaPs = { DATA_SOURCES_NOT_FOUND: '[Dâţâ šöürçëš ñöţ föüñd îñ šÿšţëm]', TRANSACTION_API_NOT_FOUND: '[Ţrâñšâçţîöñ ÂÞÎ dâţâ šöürçë ñöţ föüñd]', PAYMENT_STILL_CONFIRMING: - '[A previous payment for this order is still being confirmed. Please wait a moment, or refresh the page to check its status.]', + '[A previous payment for this order is still being confirmed. Please wait a moment before trying again.]', PAYMENT_ACTION_REQUIRED: '[Your payment needs additional verification. Please try again or choose another payment method.]', TRANSACTION_PROCESSING_FAILED: '[Fâîlëd ţö þröçëšš ţrâñšâçţîöñ]', diff --git a/packages/localizations/src/trTr.ts b/packages/localizations/src/trTr.ts index 175d2316..5b67c353 100644 --- a/packages/localizations/src/trTr.ts +++ b/packages/localizations/src/trTr.ts @@ -281,7 +281,7 @@ export const trTr = { DATA_SOURCES_NOT_FOUND: 'Veri kaynakları bulunamadı', TRANSACTION_API_NOT_FOUND: 'İşlem API veri kaynağı bulunamadı', PAYMENT_STILL_CONFIRMING: - 'Bu sipariş için önceki bir ödeme hâlâ onaylanıyor. Lütfen biraz bekleyin veya durumunu kontrol etmek için sayfayı yenileyin.', + 'Bu sipariş için önceki bir ödeme hâlâ onaylanıyor. Lütfen tekrar denemeden önce biraz bekleyin.', PAYMENT_ACTION_REQUIRED: 'Ödemeniz için ek doğrulama gerekiyor. Lütfen tekrar deneyin veya başka bir ödeme yöntemi seçin.', TRANSACTION_PROCESSING_FAILED: 'İşlem işlenemedi', diff --git a/packages/localizations/src/viVn.ts b/packages/localizations/src/viVn.ts index 8e6b2552..a5a251b0 100644 --- a/packages/localizations/src/viVn.ts +++ b/packages/localizations/src/viVn.ts @@ -279,7 +279,7 @@ export const viVn = { DATA_SOURCES_NOT_FOUND: 'Không tìm thấy nguồn dữ liệu', TRANSACTION_API_NOT_FOUND: 'Không tìm thấy nguồn dữ liệu API giao dịch', PAYMENT_STILL_CONFIRMING: - 'Một khoản thanh toán trước đó cho đơn hàng này vẫn đang được xác nhận. Vui lòng đợi một chút hoặc tải lại trang để kiểm tra trạng thái.', + 'Một khoản thanh toán trước đó cho đơn hàng này vẫn đang được xác nhận. Vui lòng đợi một chút trước khi thử lại.', PAYMENT_ACTION_REQUIRED: 'Thanh toán của bạn cần được xác minh thêm. Vui lòng thử lại hoặc chọn phương thức thanh toán khác.', TRANSACTION_PROCESSING_FAILED: 'Không thể xử lý giao dịch', diff --git a/packages/localizations/src/zhCn.ts b/packages/localizations/src/zhCn.ts index a0c01351..2823a8f1 100644 --- a/packages/localizations/src/zhCn.ts +++ b/packages/localizations/src/zhCn.ts @@ -271,7 +271,7 @@ export const zhCn = { DATA_SOURCES_NOT_FOUND: '数据源未找到', TRANSACTION_API_NOT_FOUND: '交易 API 数据源未找到', PAYMENT_STILL_CONFIRMING: - '此订单的上一笔付款仍在确认中。请稍候,或刷新页面查看其状态。', + '此订单的上一笔付款仍在确认中。请稍候再试。', PAYMENT_ACTION_REQUIRED: '您的付款需要额外验证。请重试或选择其他付款方式。', TRANSACTION_PROCESSING_FAILED: '处理交易失败', TRANSACTION_CREATION_FAILED: '查找或创建交易失败', diff --git a/packages/localizations/src/zhSg.ts b/packages/localizations/src/zhSg.ts index 246b3498..4e331bae 100644 --- a/packages/localizations/src/zhSg.ts +++ b/packages/localizations/src/zhSg.ts @@ -271,7 +271,7 @@ export const zhSg = { DATA_SOURCES_NOT_FOUND: '数据源未找到', TRANSACTION_API_NOT_FOUND: '交易API数据源未找到', PAYMENT_STILL_CONFIRMING: - '此订单的上一笔付款仍在确认中。请稍候,或刷新页面查看其状态。', + '此订单的上一笔付款仍在确认中。请稍候再试。', PAYMENT_ACTION_REQUIRED: '您的付款需要额外验证。请重试或选择其他付款方式。', TRANSACTION_PROCESSING_FAILED: '处理交易失败', TRANSACTION_CREATION_FAILED: '查找或创建交易失败', diff --git a/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts index 134f8625..7075f553 100644 --- a/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts +++ b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts @@ -6,12 +6,10 @@ import { checkoutMutationKeys, checkoutQueryKeys, } from '@/components/checkout/utils/query-keys'; +import { CheckoutErrorAlreadyReported } from './checkout-error-reported'; const DEFAULT_TIMEOUT_MS = 10_000; const POLL_INTERVAL_MS = 50; - -import { CheckoutErrorAlreadyReported } from './checkout-error-reported'; - const CHECKOUT_SYNC_ERROR = 'DRAFT_ORDER_UPDATE_FAILED'; interface FlushCheckoutSyncOptions {