diff --git a/.changeset/bright-cards-handle-3ds.md b/.changeset/bright-cards-handle-3ds.md
new file mode 100644
index 00000000..628b005c
--- /dev/null
+++ b/.changeset/bright-cards-handle-3ds.md
@@ -0,0 +1,8 @@
+---
+'@godaddy/react': patch
+'@godaddy/localizations': patch
+---
+
+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.
diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts
index 33dfc622..693ad835 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_ACTION_REQUIRED:
+ 'Deine Zahlung erfordert eine zusätzliche Bestätigung. Versuche es erneut oder wähle eine andere Zahlungsmethode.',
TRANSACTION_PROCESSING_FAILED:
'Verarbeitung der Transaktion fehlgeschlagen',
TRANSACTION_CREATION_FAILED:
diff --git a/packages/localizations/src/enAu.ts b/packages/localizations/src/enAu.ts
index 59815771..489c8bf0 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_ACTION_REQUIRED:
+ 'Your payment needs additional verification. Please try again or choose another payment method.',
TRANSACTION_PROCESSING_FAILED: 'Failed to process transaction',
TRANSACTION_CREATION_FAILED: 'Failed to find or create transaction',
ORDER_OPENING_FAILED: 'Failed to open order',
diff --git a/packages/localizations/src/enIe.ts b/packages/localizations/src/enIe.ts
index 89470301..223fd71e 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_ACTION_REQUIRED:
+ 'Your payment needs additional verification. Please try again or choose another payment method.',
TRANSACTION_PROCESSING_FAILED: 'Failed to process transaction',
TRANSACTION_CREATION_FAILED: 'Failed to find or create transaction',
ORDER_OPENING_FAILED: 'Failed to open order',
diff --git a/packages/localizations/src/enUs.ts b/packages/localizations/src/enUs.ts
index 95a556da..46afd1ed 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_ACTION_REQUIRED:
+ 'Your payment needs additional verification. Please try again or choose another payment method.',
TRANSACTION_PROCESSING_FAILED: 'Failed to process transaction',
TRANSACTION_CREATION_FAILED: 'Failed to find or create transaction',
ORDER_OPENING_FAILED: 'Failed to open order',
diff --git a/packages/localizations/src/esAr.ts b/packages/localizations/src/esAr.ts
index 8128fee0..dc11d7b6 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_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',
TRANSACTION_CREATION_FAILED: 'Error al encontrar o crear transacción',
ORDER_OPENING_FAILED: 'Error al abrir pedido',
diff --git a/packages/localizations/src/esCl.ts b/packages/localizations/src/esCl.ts
index ccb43c9a..f46b1c60 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_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',
TRANSACTION_CREATION_FAILED: 'Error al encontrar o crear transacción',
ORDER_OPENING_FAILED: 'Error al abrir pedido',
diff --git a/packages/localizations/src/esCo.ts b/packages/localizations/src/esCo.ts
index b80b2f9c..acdc1229 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_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',
TRANSACTION_CREATION_FAILED: 'Error al encontrar o crear transacción',
ORDER_OPENING_FAILED: 'Error al abrir pedido',
diff --git a/packages/localizations/src/esEs.ts b/packages/localizations/src/esEs.ts
index 013cf3a6..440d8b7a 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_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',
TRANSACTION_CREATION_FAILED: 'No se pudo encontrar o crear la transacción',
ORDER_OPENING_FAILED: 'No se pudo abrir el pedido',
diff --git a/packages/localizations/src/esMx.ts b/packages/localizations/src/esMx.ts
index 171dc3c8..7626582a 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_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',
TRANSACTION_CREATION_FAILED: 'Error al encontrar o crear transacción',
ORDER_OPENING_FAILED: 'Error al abrir pedido',
diff --git a/packages/localizations/src/esPe.ts b/packages/localizations/src/esPe.ts
index 3d1f4f36..ce5b55a8 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_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',
TRANSACTION_CREATION_FAILED: 'Error al encontrar o crear transacción',
ORDER_OPENING_FAILED: 'Error al abrir pedido',
diff --git a/packages/localizations/src/esUs.ts b/packages/localizations/src/esUs.ts
index fc77429b..ad3e6bfd 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_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',
TRANSACTION_CREATION_FAILED: 'Error al encontrar o crear transacción',
ORDER_OPENING_FAILED: 'Error al abrir pedido',
diff --git a/packages/localizations/src/frCa.ts b/packages/localizations/src/frCa.ts
index 4f4eed86..dc675271 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_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',
TRANSACTION_CREATION_FAILED:
'Échec de la recherche ou création de transaction',
diff --git a/packages/localizations/src/frFr.ts b/packages/localizations/src/frFr.ts
index 09d4c91a..8fa34c4f 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_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',
TRANSACTION_CREATION_FAILED:
'Échec de la recherche ou de la création de la transaction',
diff --git a/packages/localizations/src/idId.ts b/packages/localizations/src/idId.ts
index 4bdc412e..c067c1be 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_ACTION_REQUIRED:
+ 'Pembayaran Anda memerlukan verifikasi tambahan. Coba lagi atau pilih metode pembayaran lain.',
TRANSACTION_PROCESSING_FAILED: 'Gagal memproses transaksi',
TRANSACTION_CREATION_FAILED: 'Gagal menemukan atau membuat transaksi',
ORDER_OPENING_FAILED: 'Gagal membuka pesanan',
diff --git a/packages/localizations/src/itIt.ts b/packages/localizations/src/itIt.ts
index de2515f5..afe1aa51 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_ACTION_REQUIRED:
+ 'Il pagamento richiede una verifica aggiuntiva. Riprova o scegli un altro metodo di pagamento.',
TRANSACTION_PROCESSING_FAILED: "Errore nell'elaborazione della transazione",
TRANSACTION_CREATION_FAILED:
'Errore nella ricerca o creazione della transazione',
diff --git a/packages/localizations/src/ptBr.ts b/packages/localizations/src/ptBr.ts
index d47a9bba..c59ae720 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_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',
TRANSACTION_CREATION_FAILED: 'Falha ao encontrar ou criar transação',
ORDER_OPENING_FAILED: 'Falha ao abrir pedido',
diff --git a/packages/localizations/src/qaPs.ts b/packages/localizations/src/qaPs.ts
index 208d5935..961e5c78 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_ACTION_REQUIRED:
+ '[Your payment needs additional verification. Please try again or choose another payment method.]',
TRANSACTION_PROCESSING_FAILED: '[Fâîlëd ţö þröçëšš ţrâñšâçţîöñ]',
TRANSACTION_CREATION_FAILED: '[Fâîlëd ţö fîñd ör çrëâţë ţrâñšâçţîöñ]',
ORDER_OPENING_FAILED: '[Fâîlëd ţö öþëñ ördër fôr þröçëššîñg]',
diff --git a/packages/localizations/src/trTr.ts b/packages/localizations/src/trTr.ts
index daa63b5e..1244116c 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_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',
TRANSACTION_CREATION_FAILED: 'İşlem bulunamadı veya oluşturulamadı',
ORDER_OPENING_FAILED: 'Sipariş açılamadı',
diff --git a/packages/localizations/src/viVn.ts b/packages/localizations/src/viVn.ts
index 37ff3b91..04ed1f20 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_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',
TRANSACTION_CREATION_FAILED: 'Không thể tìm hoặc tạo giao dịch',
ORDER_OPENING_FAILED: 'Không thể mở đơn hàng',
diff --git a/packages/localizations/src/zhCn.ts b/packages/localizations/src/zhCn.ts
index 19eb5bf9..866be391 100644
--- a/packages/localizations/src/zhCn.ts
+++ b/packages/localizations/src/zhCn.ts
@@ -270,6 +270,7 @@ export const zhCn = {
DRAFT_ORDER_RETRIEVAL_FAILED: '获取订单失败',
DATA_SOURCES_NOT_FOUND: '数据源未找到',
TRANSACTION_API_NOT_FOUND: '交易 API 数据源未找到',
+ PAYMENT_ACTION_REQUIRED: '您的付款需要额外验证。请重试或选择其他付款方式。',
TRANSACTION_PROCESSING_FAILED: '处理交易失败',
TRANSACTION_CREATION_FAILED: '查找或创建交易失败',
ORDER_OPENING_FAILED: '打开订单失败',
diff --git a/packages/localizations/src/zhSg.ts b/packages/localizations/src/zhSg.ts
index 52f1938b..4345466b 100644
--- a/packages/localizations/src/zhSg.ts
+++ b/packages/localizations/src/zhSg.ts
@@ -270,6 +270,7 @@ export const zhSg = {
DRAFT_ORDER_RETRIEVAL_FAILED: '检索订单失败',
DATA_SOURCES_NOT_FOUND: '数据源未找到',
TRANSACTION_API_NOT_FOUND: '交易API数据源未找到',
+ PAYMENT_ACTION_REQUIRED: '您的付款需要额外验证。请重试或选择其他付款方式。',
TRANSACTION_PROCESSING_FAILED: '处理交易失败',
TRANSACTION_CREATION_FAILED: '查找或创建交易失败',
ORDER_OPENING_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 1724ab89..8e6876f0 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
@@ -1,7 +1,7 @@
-import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { useCheckoutContext } from '@/components/checkout/checkout';
import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods';
import {
@@ -9,6 +9,7 @@ import {
PaymentProvider,
useConfirmCheckout,
} from '@/components/checkout/payment/utils/use-confirm-checkout';
+import { confirmCheckout, getDraftOrder } from '@/lib/godaddy/godaddy';
import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors';
import {
buildCheckoutSession,
@@ -19,6 +20,7 @@ import {
clearOperations,
getOperations,
mockGodaddyApi,
+ mockWindowLocation,
type RenderCheckoutOptions,
renderCheckout,
setApiError,
@@ -346,6 +348,47 @@ describe('Checkout confirm errors', () => {
expect(window.location.href).not.toContain('should-not-go-here');
});
+ it('refreshes after a lost confirmation response and redirects the paid order', async () => {
+ mockWindowLocation();
+ const successUrl = 'https://merchant.example/success';
+ const { session, user } = renderCheckoutWithConfirmSeam(
+ {
+ sessionOverrides: { ...offlineSessionOverrides(), successUrl },
+ },
+ {
+ isExpress: true,
+ paymentType: 'offline',
+ paymentProvider: PaymentProvider.OFFLINE,
+ }
+ );
+ await waitForCheckoutReady();
+ vi.mocked(confirmCheckout).mockImplementationOnce(async () => {
+ vi.mocked(getDraftOrder).mockResolvedValue({
+ checkoutSession: {
+ ...session,
+ draftOrder: buildDraftOrder({
+ statuses: { status: 'OPEN', paymentStatus: 'PAID' },
+ }),
+ },
+ });
+ throw new Error('Confirmation response lost');
+ });
+ await user.click(
+ await screen.findByRole('button', { name: /confirm seam/i })
+ );
+ expect(await screen.findByRole('status')).toHaveTextContent(
+ 'Payment successful'
+ );
+ expect(
+ screen.queryByRole('button', { name: /confirm seam/i })
+ ).not.toBeInTheDocument();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1100);
+ });
+ expect(window.location.href).toBe(successUrl);
+ expect(confirmCheckout).toHaveBeenCalledTimes(1);
+ });
+
it('surfaces DRAFT_ORDER_UPDATE_FAILED when the in-confirm draft-order fetch fails', async () => {
const draftOrder = buildDraftOrder();
const session = buildCheckoutSession({
diff --git a/packages/react/src/components/checkout/__tests__/checkout-error-list.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-error-list.test.tsx
index 41564091..e03ed9ef 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-error-list.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-error-list.test.tsx
@@ -71,6 +71,16 @@ describe('CheckoutErrorList', () => {
expect(screen.getByText('CUSTOM_RAW_CODE')).toBeInTheDocument();
});
+ it('renders a localized action-required message', () => {
+ renderErrorList({ checkoutErrors: ['PAYMENT_ACTION_REQUIRED'] });
+ expect(
+ screen.getByText(enUs.apiErrors.PAYMENT_ACTION_REQUIRED)
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText('PAYMENT_ACTION_REQUIRED')
+ ).not.toBeInTheDocument();
+ });
+
it('renders checkout disabled copy with checkout errors', () => {
renderErrorList({
checkoutErrors: ['TRANSACTION_PROCESSING_FAILED'],
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
new file mode 100644
index 00000000..58e7e2d2
--- /dev/null
+++ b/packages/react/src/components/checkout/__tests__/checkout-paid-order.test.tsx
@@ -0,0 +1,101 @@
+import { act, screen } 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,
+ waitForCheckoutReady,
+} from './checkout-test-env';
+
+describe('Checkout paid-order recovery', () => {
+ beforeEach(() => {
+ mockWindowLocation();
+ });
+
+ it('redirects a paid order to the session success URL without showing payment controls', async () => {
+ const successUrl = 'https://merchant.example/success';
+ renderCheckout({
+ sessionOverrides: { successUrl },
+ draftOrderOverrides: {
+ statuses: { status: 'OPEN', paymentStatus: 'PAID' },
+ },
+ });
+
+ expect(await screen.findByRole('status')).toHaveTextContent(
+ 'Payment successful'
+ );
+ expect(
+ screen.queryByRole('button', { name: /pay now/i })
+ ).not.toBeInTheDocument();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1100);
+ });
+ expect(window.location.href).toBe(successUrl);
+ expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0);
+ });
+
+ it('keeps payment controls hidden when a paid order has no success URL', async () => {
+ const initialUrl = window.location.href;
+ renderCheckout({
+ 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(initialUrl);
+ expect(
+ screen.queryByRole('button', { name: /pay now/i })
+ ).not.toBeInTheDocument();
+ });
+
+ it.each(['UNPAID', 'PENDING'])(
+ 'does not redirect an order whose payment status is %s',
+ async paymentStatus => {
+ const initialUrl = window.location.href;
+ renderCheckout({
+ sessionOverrides: { successUrl: 'https://merchant.example/success' },
+ draftOrderOverrides: { statuses: { paymentStatus } },
+ });
+ await waitForCheckoutReady();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1100);
+ });
+ expect(window.location.href).toBe(initialUrl);
+ expect(screen.queryByText('Payment successful')).not.toBeInTheDocument();
+ }
+ );
+
+ it('uses the loaded order status when the session snapshot is still unpaid', async () => {
+ const successUrl = 'https://merchant.example/success';
+ const { queryClient, session } = renderCheckout({
+ sessionOverrides: { successUrl },
+ draftOrderOverrides: { statuses: { paymentStatus: 'UNPAID' } },
+ });
+ await waitForCheckoutReady();
+ act(() => {
+ queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), {
+ checkoutSession: {
+ ...session,
+ draftOrder: buildDraftOrder({
+ 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);
+ });
+});
diff --git a/packages/react/src/components/checkout/__tests__/checkout-stripe-recovery.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-stripe-recovery.test.tsx
new file mode 100644
index 00000000..aaf96186
--- /dev/null
+++ b/packages/react/src/components/checkout/__tests__/checkout-stripe-recovery.test.tsx
@@ -0,0 +1,173 @@
+import { act, screen, waitFor } from '@testing-library/react';
+import type React from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as godaddyApi from '@/lib/godaddy/godaddy';
+import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors';
+import {
+ buildCheckoutSession,
+ buildDraftOrder,
+ renderCheckout,
+ restoreWindowLocation,
+ setupCheckoutTestGlobals,
+ waitForCheckoutReady,
+} from './checkout-test-utils';
+
+const stripe = vi.hoisted(() => ({
+ createPaymentMethod: vi.fn(),
+ handleNextAction: vi.fn(),
+}));
+const elements = vi.hoisted(() => ({
+ getElement: vi.fn(() => ({})),
+ update: vi.fn(),
+}));
+
+// Keep the real Checkout, PaymentForm, StripeProvider, button, and confirm hook.
+vi.mock('@stripe/react-stripe-js', () => ({
+ Elements: ({ children }: { children: React.ReactNode }) => children,
+ CardElement: () =>
Stripe card field
,
+ useStripe: () => stripe,
+ useElements: () => elements,
+}));
+vi.mock(
+ '@/components/checkout/payment/utils/use-stripe-payment-intent',
+ () => ({
+ useStripePaymentIntent: () => ({
+ stripePromise: stripe,
+ currency: 'usd',
+ clientSecret: null,
+ isLoading: false,
+ amount: 1000,
+ }),
+ })
+);
+
+beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ setupCheckoutTestGlobals();
+ stripe.createPaymentMethod
+ .mockReset()
+ .mockResolvedValue({ paymentMethod: { id: 'pm_original' } });
+ stripe.handleNextAction.mockReset();
+});
+
+afterEach(() => {
+ act(() => {
+ vi.runOnlyPendingTimers();
+ });
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ restoreWindowLocation();
+});
+
+describe('Stripe recovery through the payment spinner', () => {
+ it.each([
+ ['finalization', false],
+ ['SDK transport', false],
+ ['finalization', true],
+ ['SDK transport', true],
+ ] as const)(
+ 'reuses the intent after %s failure and button remount (tips enabled: %s)',
+ async (failure, enableTips) => {
+ const draftOrder = buildDraftOrder({
+ totals: {
+ subTotal: { value: 2500, currencyCode: 'USD' },
+ discountTotal: { value: 0, currencyCode: 'USD' },
+ total: { value: 2500, currencyCode: 'USD' },
+ },
+ });
+ const session = buildCheckoutSession({
+ draftOrder,
+ enableTips,
+ enableShipping: false,
+ enableLocalPickup: false,
+ enableBillingAddressCollection: false,
+ enableTaxCollection: false,
+ paymentMethods: {
+ card: { processor: 'stripe', checkoutTypes: ['standard'] },
+ },
+ });
+ const { user } = renderCheckout({
+ session,
+ draftOrder,
+ checkoutProps: { stripeConfig: { publishableKey: 'pk_test' } },
+ });
+ await waitForCheckoutReady();
+ if (enableTips) {
+ await user.click(await screen.findByRole('radio', { name: /20%/ }));
+ }
+
+ const confirm = vi.mocked(godaddyApi.confirmCheckout);
+ confirm.mockRejectedValueOnce(
+ new GraphQLErrorWithCodes([
+ {
+ code: 'PAYMENT_ACTION_REQUIRED',
+ extensions: {
+ paymentResult: {
+ status: 'ACTION_REQUIRED',
+ provider: 'STRIPE',
+ paymentReference: 'pi_original',
+ nextStep: {
+ type: 'SDK_ACTION',
+ sdk: 'STRIPE_JS',
+ action: 'HANDLE_NEXT_ACTION',
+ clientSecret: 'pi_original_secret',
+ },
+ },
+ },
+ },
+ ])
+ );
+ if (failure === 'finalization')
+ confirm.mockRejectedValueOnce(new Error('Connection lost'));
+
+ let finishAction!: (value: unknown) => void;
+ let failAction!: (reason: Error) => void;
+ const action = new Promise((resolve, reject) => {
+ finishAction = resolve;
+ failAction = reject;
+ });
+ stripe.handleNextAction.mockReturnValueOnce(action);
+ const originalButton = await screen.findByRole('button', {
+ name: /pay now/i,
+ });
+ await user.click(originalButton);
+ await waitFor(() =>
+ expect(stripe.handleNextAction).toHaveBeenCalledOnce()
+ );
+ expect(originalButton).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /processing/i })
+ ).toBeDisabled();
+
+ await act(async () => {
+ if (failure === 'SDK transport')
+ failAction(new Error('Connection lost'));
+ else
+ finishAction({
+ paymentIntent: { id: 'pi_original', status: 'succeeded' },
+ });
+ });
+ const retryButton = await screen.findByRole('button', {
+ name: /pay now/i,
+ });
+ expect(retryButton).not.toBe(originalButton);
+ await waitFor(() => expect(retryButton).toBeEnabled());
+ await user.click(retryButton);
+ const expectedTokens =
+ failure === 'finalization'
+ ? ['pm_original', 'pi_original', 'pi_original']
+ : ['pm_original', 'pi_original'];
+ await waitFor(() =>
+ expect(confirm.mock.calls.map(([input]) => input.paymentToken)).toEqual(
+ expectedTokens
+ )
+ );
+ for (const [input] of confirm.mock.calls) {
+ if (enableTips) expect(input.tipAmount).toBe(500);
+ else expect(input).not.toHaveProperty('tipAmount');
+ }
+ expect(stripe.createPaymentMethod).toHaveBeenCalledOnce();
+ expect(stripe.handleNextAction).toHaveBeenCalledOnce();
+ }
+ );
+});
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
new file mode 100644
index 00000000..49de80aa
--- /dev/null
+++ b/packages/react/src/components/checkout/__tests__/express-checkout-paid-order.test.tsx
@@ -0,0 +1,107 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { DraftOrderExpressCheckout } from '@/components/checkout/express-checkout/express-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 {
+ buildCheckoutSession,
+ buildDraftOrder,
+ createTestQueryClient,
+ mockGodaddyApi,
+ mockWindowLocation,
+} from './checkout-test-env';
+
+vi.mock('@/components/checkout/payment/payment-method-renderer', () => ({
+ PaymentMethodRenderer: () => {
+ const confirmation = useConfirmExpressCheckout();
+ return (
+
+ );
+ },
+}));
+
+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 2176dd76..f1e282f0 100644
--- a/packages/react/src/components/checkout/form/checkout-form-container.tsx
+++ b/packages/react/src/components/checkout/form/checkout-form-container.tsx
@@ -14,11 +14,13 @@ import {
useDraftOrderProductsMap,
useRefreshProductsWhenLineItemsChange,
} from '@/components/checkout/order/use-draft-order-products';
+import { usePaidOrderRedirect } from '@/components/checkout/order/use-paid-order-redirect';
import {
mapOrderToFormValues,
mapSkusToItemsDisplay,
} from '@/components/checkout/utils/checkout-transformers';
import { getFulfillmentSummary } from '@/components/checkout/utils/fulfillment';
+import { useGoDaddyContext } from '@/godaddy-provider';
interface CheckoutFormContainerProps extends Omit {
validationAdapter: CheckoutValidationAdapter;
@@ -31,12 +33,15 @@ export function CheckoutFormContainer({
...props
}: CheckoutFormContainerProps) {
const { session, isConfirmingCheckout } = useCheckoutContext();
+ const { t } = useGoDaddyContext();
const draftOrderQuery = useDraftOrder();
const draftOrderLineItemsQuery = useDraftOrderLineItems();
const skusMap = useDraftOrderProductsMap();
const { data: order } = draftOrderQuery;
+ const showPaidOrder = usePaidOrderRedirect(order);
+
const { data: lineItems } = draftOrderLineItemsQuery;
useRefreshProductsWhenLineItemsChange(lineItems);
@@ -82,6 +87,10 @@ export function CheckoutFormContainer({
);
}
+ if (showPaidOrder) {
+ return {t.errors.paymentSuccessful}
;
+ }
+
return (
{
+ if (showPaidOrder) redirectToSuccessUrl(session?.successUrl);
+ }, [showPaidOrder, session?.successUrl]);
+
+ return showPaidOrder;
+}
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..9ce5bc71 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
@@ -1,7 +1,9 @@
'use client';
+import { useRef, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useCheckoutContext } from '@/components/checkout/checkout';
+import { isCheckoutConfirmationBlockedError } from '@/components/checkout/payment/utils/use-confirm-checkout';
import { useFlushCheckoutSync } from '@/components/checkout/payment/utils/use-flush-checkout-sync';
import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled';
import { useStripeCheckout } from '@/components/checkout/payment/utils/use-stripe-checkout';
@@ -14,21 +16,35 @@ export function StripeCreditCardCheckoutButton() {
const { isConfirmingCheckout } = useCheckoutContext();
const isPaymentDisabled = useIsPaymentDisabled();
const flushCheckoutSync = useFlushCheckoutSync();
- const { handleSubmit } = useStripeCheckout({ mode: 'card' });
+ const isSubmittingRef = useRef(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const { handleSubmit, isProcessingPayment } = useStripeCheckout({
+ mode: 'card',
+ });
const handleStripeCheckout = async () => {
- const valid = await form.trigger();
+ if (isSubmittingRef.current) return;
+ isSubmittingRef.current = true;
+ setIsSubmitting(true);
+ try {
+ const valid = await form.trigger();
- if (!valid) {
- const firstError = Object.keys(form.formState.errors)[0];
- if (firstError) {
- form.setFocus(firstError);
+ if (!valid) {
+ const firstError = Object.keys(form.formState.errors)[0];
+ if (firstError) {
+ form.setFocus(firstError);
+ }
+ } else {
+ const { latestOrder } = await flushCheckoutSync({
+ includeCurrentFormDiff: true,
+ });
+ await handleSubmit(undefined, latestOrder);
}
- } else {
- const { latestOrder } = await flushCheckoutSync({
- includeCurrentFormDiff: true,
- });
- await handleSubmit(undefined, latestOrder);
+ } catch (error) {
+ if (!isCheckoutConfirmationBlockedError(error)) throw error;
+ } finally {
+ isSubmittingRef.current = false;
+ setIsSubmitting(false);
}
};
@@ -36,7 +52,12 @@ export function StripeCreditCardCheckoutButton() {