Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-cards-handle-3ds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@godaddy/react': patch
---

Support Stripe 3DS next actions returned by checkout confirmation while preserving existing payment error behavior.
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -36,7 +38,9 @@ export function StripeCreditCardCheckoutButton() {
<Button
className='w-full'
size='lg'
disabled={isConfirmingCheckout || isPaymentDisabled}
disabled={
isProcessingPayment || isConfirmingCheckout || isPaymentDisabled
}
onClick={handleStripeCheckout}
>
{t.payment.payNow}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getShippingFulfillmentSyncKey } from '@/components/checkout/shipping/ut
import { isDigitalLineItem } from '@/components/checkout/utils/fulfillment';
import { useGoDaddyContext } from '@/godaddy-provider';
import { confirmCheckout } from '@/lib/godaddy/godaddy';
import { getPaymentActionRequiredResult } from '@/lib/graphql-with-errors';
import { eventIds } from '@/tracking/events';
import {
type TrackingEventId,
Expand Down Expand Up @@ -268,6 +269,20 @@ export function useConfirmCheckout() {
onError: (error: unknown, data) => {
if (isCheckoutConfirmationBlockedError(error)) return;

const paymentResult = getPaymentActionRequiredResult(error);
const nextStep = paymentResult?.nextStep;
if (
data?.paymentProvider === PaymentProvider.STRIPE &&
paymentResult?.provider === PaymentProvider.STRIPE &&
nextStep?.type === 'SDK_ACTION' &&
nextStep.sdk === 'STRIPE_JS' &&
nextStep.action === 'HANDLE_NEXT_ACTION' &&
typeof nextStep.clientSecret === 'string' &&
nextStep.clientSecret.length > 0
) {
return;
}

// Track checkout error event
track({
eventId: eventIds.checkoutError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react';
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { checkoutContext } from '@/components/checkout/checkout';
import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors';
import type { DraftOrder } from '@/types';
import { useStripeCheckout } from './use-stripe-checkout';

Expand All @@ -10,16 +11,22 @@ const mocks = vi.hoisted(() => ({
flush: vi.fn(),
buildFromOrder: vi.fn(),
createPaymentMethod: vi.fn(),
handleNextAction: vi.fn(),
confirm: vi.fn(),
confirmExpress: vi.fn(),
setCheckoutErrors: vi.fn(),
setIsConfirmingCheckout: vi.fn(),
cardElement: {},
}));

vi.mock('@stripe/react-stripe-js', () => ({
CardElement: function CardElement() {
return null;
},
useStripe: () => ({ createPaymentMethod: mocks.createPaymentMethod }),
useStripe: () => ({
createPaymentMethod: mocks.createPaymentMethod,
handleNextAction: mocks.handleNextAction,
}),
useElements: () => ({
getElement: () => mocks.cardElement,
}),
Expand Down Expand Up @@ -67,8 +74,8 @@ function Wrapper({ children }: { children: React.ReactNode }) {
value={{
session: { id: 'session-1' } as never,
isConfirmingCheckout: false,
setIsConfirmingCheckout: vi.fn(),
setCheckoutErrors: vi.fn(),
setIsConfirmingCheckout: mocks.setIsConfirmingCheckout,
setCheckoutErrors: mocks.setCheckoutErrors,
}}
>
{children}
Expand All @@ -88,6 +95,9 @@ describe('useStripeCheckout payment request resolution', () => {
mocks.createPaymentMethod.mockResolvedValue({
paymentMethod: { id: 'stripe-payment-method' },
});
mocks.handleNextAction.mockResolvedValue({
paymentIntent: { id: 'pi-confirmed' },
});
mocks.confirm.mockResolvedValue(undefined);
mocks.confirmExpress.mockResolvedValue(undefined);
});
Expand Down Expand Up @@ -120,6 +130,152 @@ describe('useStripeCheckout payment request resolution', () => {
);
});

it('handles a valid Stripe next step and retries confirmation with the PaymentIntent', async () => {
mocks.confirm
.mockRejectedValueOnce(
new GraphQLErrorWithCodes([
{
message: 'Payment requires additional customer action',
code: 'PAYMENT_ACTION_REQUIRED',
extensions: {
paymentResult: {
status: 'ACTION_REQUIRED',
provider: 'STRIPE',
paymentReference: 'pi-confirmed',
nextStep: {
type: 'SDK_ACTION',
sdk: 'STRIPE_JS',
action: 'HANDLE_NEXT_ACTION',
actionId: 'pi-confirmed',
clientSecret: 'pi-confirmed-secret',
},
},
},
},
])
)
.mockResolvedValueOnce(undefined);

const { result } = renderHook(() => useStripeCheckout({ mode: 'card' }), {
wrapper: Wrapper,
});

await act(async () => {
await result.current.handleSubmit();
});

expect(mocks.handleNextAction).toHaveBeenCalledWith({
clientSecret: 'pi-confirmed-secret',
});
expect(mocks.confirm).toHaveBeenNthCalledWith(1, {
paymentToken: 'stripe-payment-method',
paymentType: 'card',
paymentProvider: 'STRIPE',
});
expect(mocks.confirm).toHaveBeenNthCalledWith(2, {
paymentToken: 'pi-confirmed',
paymentType: 'card',
paymentProvider: 'STRIPE',
});
expect(mocks.setIsConfirmingCheckout).not.toHaveBeenCalledWith(false);
});

it('does not run Stripe next actions for legacy GraphQL errors without the 3DS extension', async () => {
mocks.confirm.mockRejectedValueOnce(
new GraphQLErrorWithCodes([
{
message: 'Failed to process transaction',
code: 'TRANSACTION_PROCESSING_FAILED',
},
])
);

const { result } = renderHook(() => useStripeCheckout({ mode: 'card' }), {
wrapper: Wrapper,
});

await act(async () => {
await result.current.handleSubmit();
});

expect(mocks.handleNextAction).not.toHaveBeenCalled();
expect(mocks.setCheckoutErrors).toHaveBeenCalledWith([
'TRANSACTION_PROCESSING_FAILED',
]);
expect(mocks.setIsConfirmingCheckout).toHaveBeenCalledWith(false);
});

it('does not run Stripe for malformed action-required extensions', async () => {
mocks.confirm.mockRejectedValueOnce(
new GraphQLErrorWithCodes([
{
code: 'PAYMENT_ACTION_REQUIRED',
extensions: {
paymentResult: {
status: 'ACTION_REQUIRED',
provider: 'STRIPE',
nextStep: {
type: 'SDK_ACTION',
sdk: 'STRIPE_JS',
action: 'HANDLE_NEXT_ACTION',
},
},
},
},
])
);

const { result } = renderHook(() => useStripeCheckout({ mode: 'card' }), {
wrapper: Wrapper,
});

await act(async () => {
await result.current.handleSubmit();
});

expect(mocks.handleNextAction).not.toHaveBeenCalled();
expect(mocks.setIsConfirmingCheckout).toHaveBeenCalledWith(false);
});

it('unlocks checkout when Stripe cannot complete the next action', async () => {
mocks.confirm.mockRejectedValueOnce(
new GraphQLErrorWithCodes([
{
code: 'PAYMENT_ACTION_REQUIRED',
extensions: {
paymentResult: {
status: 'ACTION_REQUIRED',
provider: 'STRIPE',
nextStep: {
type: 'SDK_ACTION',
sdk: 'STRIPE_JS',
action: 'HANDLE_NEXT_ACTION',
clientSecret: 'pi-secret',
},
},
},
},
])
);
mocks.handleNextAction.mockResolvedValueOnce({
error: { code: 'payment_intent_authentication_failure' },
});

const { result } = renderHook(() => useStripeCheckout({ mode: 'card' }), {
wrapper: Wrapper,
});

await act(async () => {
await result.current.handleSubmit();
});

expect(mocks.confirm).toHaveBeenCalledTimes(1);
expect(mocks.setCheckoutErrors).toHaveBeenCalledWith([
'payment_intent_authentication_failure',
]);
expect(mocks.setIsConfirmingCheckout).toHaveBeenCalledWith(false);
});

it('tokenizes express billing from the wallet event without flushing form data', async () => {
const { result } = renderHook(
() => useStripeCheckout({ mode: 'express' }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import {
} from '@/components/checkout/payment/utils/use-confirm-checkout';
import { useConfirmExpressCheckout } from '@/components/checkout/payment/utils/use-confirm-express-checkout';
import { useFlushCheckoutSync } from '@/components/checkout/payment/utils/use-flush-checkout-sync';
import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors';
import {
GraphQLErrorWithCodes,
getPaymentActionRequiredResult,
} from '@/lib/graphql-with-errors';
import type {
CalculatedAdjustments,
CalculatedTaxes,
Expand Down Expand Up @@ -50,6 +53,28 @@ export function buildStripeExpressPaymentMethodParams(
};
}

type StripeNextAction = {
clientSecret: string;
};

function getStripeNextAction(error: unknown): StripeNextAction | undefined {
const paymentResult = getPaymentActionRequiredResult(error);
const nextStep = paymentResult?.nextStep;

if (
paymentResult?.provider !== PaymentProvider.STRIPE ||
nextStep?.type !== 'SDK_ACTION' ||
nextStep.sdk !== 'STRIPE_JS' ||
nextStep.action !== 'HANDLE_NEXT_ACTION' ||
typeof nextStep.clientSecret !== 'string' ||
nextStep.clientSecret.length === 0
) {
return undefined;
}

return { clientSecret: nextStep.clientSecret };
}

export type StripeExpressCheckoutData = {
// Stripe confirm event data
event: StripeExpressCheckoutElementConfirmEvent;
Expand All @@ -69,7 +94,7 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) {
const elements = useElements();
const confirmCheckout = useConfirmCheckout();
const confirmExpressCheckout = useConfirmExpressCheckout();
const { setCheckoutErrors } = useCheckoutContext();
const { setCheckoutErrors, setIsConfirmingCheckout } = useCheckoutContext();
const { stripePaymentMethodParams, buildPaymentRequestsFromOrder } =
useBuildPaymentRequest();
const flushCheckoutSync = useFlushCheckoutSync();
Expand Down Expand Up @@ -115,17 +140,58 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) {
}

if (paymentMethod) {
const confirmInput = {
paymentToken: paymentMethod.id,
paymentType: PaymentMethodType.CREDIT_CARD,
paymentProvider: PaymentProvider.STRIPE,
};

try {
await confirmCheckout.mutateAsync({
paymentToken: paymentMethod.id,
paymentType: PaymentMethodType.CREDIT_CARD,
paymentProvider: PaymentProvider.STRIPE,
});
await confirmCheckout.mutateAsync(confirmInput);
} catch (err: unknown) {
if (err instanceof GraphQLErrorWithCodes) {
setCheckoutErrors(err.codes);
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;
}

try {
const actionResult = await stripe.handleNextAction({
clientSecret: nextAction.clientSecret,
});

if (actionResult.error || !actionResult.paymentIntent?.id) {
setCheckoutErrors([
actionResult.error?.code || 'TRANSACTION_PROCESSING_FAILED',
]);
setIsConfirmingCheckout(false);
return;
}

await confirmCheckout.mutateAsync({
...confirmInput,
paymentToken: actionResult.paymentIntent.id,
});
} catch (finalizationError: unknown) {
const isRepeatedActionRequired = Boolean(
getPaymentActionRequiredResult(finalizationError)
);
setCheckoutErrors(
finalizationError instanceof GraphQLErrorWithCodes &&
!isRepeatedActionRequired
? finalizationError.codes
: ['TRANSACTION_PROCESSING_FAILED']
);
setIsConfirmingCheckout(false);
}
// Other errors are silently ignored
}
} else {
setCheckoutErrors(['TRANSACTION_PROCESSING_FAILED']);
Expand Down Expand Up @@ -280,6 +346,7 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) {
buildPaymentRequestsFromOrder,
confirmExpressCheckout.mutateAsync,
setCheckoutErrors,
setIsConfirmingCheckout,
stripePaymentMethodParams,
]
);
Expand Down
Loading