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() {