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..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 @@ -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 } from '@/lib/godaddy/godaddy'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { buildCheckoutSession, @@ -19,9 +20,11 @@ import { clearOperations, getOperations, mockGodaddyApi, + mockWindowLocation, type RenderCheckoutOptions, renderCheckout, setApiError, + setCurrentDraftOrder, waitForCheckoutReady, waitForOperation, } from './checkout-test-env'; @@ -98,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(); @@ -112,7 +115,7 @@ function ConfirmSeamButton({ form.setValue(key, value); } } - void confirmCheckout + void confirmation .mutateAsync({ paymentToken: isExpress ? 'express-nonce' : '', paymentType, @@ -140,7 +143,7 @@ function ConfirmSeamButton({ } function DuplicateConfirmSeamButton() { - const confirmCheckout = useConfirmCheckout(); + const confirmation = useConfirmCheckout(); const form = useFormContext(); const [secondResult, setSecondResult] = useState('idle'); @@ -156,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 => { @@ -346,6 +349,45 @@ 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 { user } = renderCheckoutWithConfirmSeam( + { + sessionOverrides: { ...offlineSessionOverrides(), successUrl }, + }, + { + isExpress: true, + paymentType: 'offline', + paymentProvider: PaymentProvider.OFFLINE, + } + ); + await waitForCheckoutReady(); + vi.mocked(confirmCheckout).mockImplementationOnce(async () => { + // 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( + 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..e24e8494 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-paid-order.test.tsx @@ -0,0 +1,193 @@ +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 { + 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('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({ + 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; + 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('redirects when a refreshed order status becomes paid', async () => { + const successUrl = 'https://merchant.example/success'; + const { queryClient, session } = renderCheckout({ + sessionOverrides: { successUrl }, + draftOrderOverrides: { statuses: { paymentStatus: 'UNPAID' } }, + }); + await waitForCheckoutReady(); + act(() => { + queryClient.setQueryData(checkoutQueryKeys.orderStatus(session.id), { + checkoutSession: { + id: session.id, + orderStatus: { 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__/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 new file mode 100644 index 00000000..d4e07872 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/express-checkout-paid-order.test.tsx @@ -0,0 +1,106 @@ +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 } from '@/lib/godaddy/godaddy'; +import { + buildCheckoutSession, + buildDraftOrder, + createTestQueryClient, + mockGodaddyApi, + mockWindowLocation, + setCurrentDraftOrder, +} 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 () => { + // 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); + } + 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..80c2a638 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,25 @@ function DraftOrderExpressCheckoutButtons() { ); } +function ExpressCheckoutContent() { + const { t } = useGoDaddyContext(); + const { data: order, isLoading } = useDraftOrder(); + const { showPaidOrder, isLoadingOrderStatus } = usePaidOrderRedirect({ + order, + isDraftOrderLoading: isLoading, + }); + + if (showPaidOrder) + return
{t.errors.paymentSuccessful}
; + if (isLoading || (!order && isLoadingOrderStatus)) return null; + + return ( + + + + ); +} + export function DraftOrderExpressCheckout(props: ExpressCheckoutProps) { const { session, @@ -140,9 +161,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..51280845 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,18 @@ 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, isLoadingOrderStatus } = usePaidOrderRedirect({ + order, + isDraftOrderLoading: draftOrderQuery.isLoading, + }); + const { data: lineItems } = draftOrderLineItemsQuery; useRefreshProductsWhenLineItemsChange(lineItems); @@ -68,7 +76,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; @@ -76,7 +95,12 @@ export function CheckoutFormContainer({ } } - if (props.isLoading || draftOrderQuery.isLoading || isLoadingJWT) { + if ( + props.isLoading || + draftOrderQuery.isLoading || + isLoadingJWT || + (!order && isLoadingOrderStatus) + ) { return ( props.loadingFallback ?? ); 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 new file mode 100644 index 00000000..817d7ef9 --- /dev/null +++ b/packages/react/src/components/checkout/order/use-checkout-order-status.ts @@ -0,0 +1,45 @@ +import { useQuery } from '@tanstack/react-query'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { useGoDaddyContext } from '@/godaddy-provider'; +import { getCheckoutOrderStatus } from '@/lib/godaddy/godaddy'; + +type OrderStatus = { + status?: string | null; + paymentStatus?: string | null; +} | null; + +// PAID is a captured payment; PENDING is an offline checkout awaiting collection. +// Either way the shopper has nothing left to pay, matching confirmCheckoutSession. +export function isCheckoutComplete(orderStatus: OrderStatus | undefined) { + const status = orderStatus?.status?.trim().toUpperCase(); + const paymentStatus = orderStatus?.paymentStatus?.trim().toUpperCase(); + return ( + (paymentStatus === 'PAID' || paymentStatus === 'PENDING') && + status !== 'CANCELED' + ); +} + +export function useCheckoutOrderStatus({ enabled }: { enabled: boolean }) { + const { session, jwt } = useCheckoutContext(); + const { apiHost } = useGoDaddyContext(); + + return useQuery({ + queryKey: checkoutQueryKeys.orderStatus(session?.id), + queryFn: () => + jwt + ? getCheckoutOrderStatus({ accessToken: jwt }, apiHost) + : getCheckoutOrderStatus(session, apiHost), + // 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, + // 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 new file mode 100644 index 00000000..b7c5e588 --- /dev/null +++ b/packages/react/src/components/checkout/order/use-paid-order-redirect.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; +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 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 needsOrderStatus = !isDraftOrderLoading && !order; + const orderStatusQuery = useCheckoutOrderStatus({ + enabled: needsOrderStatus, + }); + const showPaidOrder = + isCheckoutComplete(orderStatusQuery.data) && !isConfirmingCheckout; + + useEffect(() => { + if (showPaidOrder) redirectToSuccessUrl(session?.successUrl); + }, [showPaidOrder, session?.successUrl]); + + 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/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() {