diff --git a/.changeset/bright-cards-handle-3ds.md b/.changeset/bright-cards-handle-3ds.md new file mode 100644 index 000000000..eb9d121be --- /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. While a 3-D Secure payment's outcome is unknown, every payment method is blocked from starting another payment for the checkout. Update express payment tracking to reflect the payment method used and successful checkout completion. diff --git a/.gitignore b/.gitignore index be44de1dd..34d0665fd 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ dist .DS_Store .env .env.local + +# Claude Code local state (worktrees, settings) +.claude/ diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts index 33dfc6223..4b9cb6432 100644 --- a/packages/localizations/src/deDe.ts +++ b/packages/localizations/src/deDe.ts @@ -291,6 +291,10 @@ export const deDe = { DRAFT_ORDER_RETRIEVAL_FAILED: 'Abrufen der Bestellung fehlgeschlagen', DATA_SOURCES_NOT_FOUND: 'Datenquellen nicht gefunden', TRANSACTION_API_NOT_FOUND: 'Transaktions-API-Datenquelle nicht gefunden', + PAYMENT_STILL_CONFIRMING: + 'Eine vorherige Zahlung für diese Bestellung wird noch bestätigt. Warte einen Moment, bevor du es erneut versuchst.', + PAYMENT_ACTION_REQUIRED: + 'Deine Zahlung erfordert eine zusätzliche Bestätigung. Versuche es erneut oder wähle eine andere Zahlungsmethode.', TRANSACTION_PROCESSING_FAILED: 'Verarbeitung der Transaktion fehlgeschlagen', TRANSACTION_CREATION_FAILED: diff --git a/packages/localizations/src/enAu.ts b/packages/localizations/src/enAu.ts index 598157713..552ead95e 100644 --- a/packages/localizations/src/enAu.ts +++ b/packages/localizations/src/enAu.ts @@ -278,6 +278,10 @@ export const enAu = { DRAFT_ORDER_RETRIEVAL_FAILED: 'Failed to retrieve order', DATA_SOURCES_NOT_FOUND: 'Data sources not found', TRANSACTION_API_NOT_FOUND: 'Transaction API data source not found', + PAYMENT_STILL_CONFIRMING: + 'A previous payment for this order is still being confirmed. Please wait a moment before trying again.', + PAYMENT_ACTION_REQUIRED: + 'Your payment needs additional verification. Please try again or choose another payment method.', TRANSACTION_PROCESSING_FAILED: 'Failed to process transaction', 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 894703013..62beb2b29 100644 --- a/packages/localizations/src/enIe.ts +++ b/packages/localizations/src/enIe.ts @@ -278,6 +278,10 @@ export const enIe = { DRAFT_ORDER_RETRIEVAL_FAILED: 'Failed to retrieve order', DATA_SOURCES_NOT_FOUND: 'Data sources not found', TRANSACTION_API_NOT_FOUND: 'Transaction API data source not found', + PAYMENT_STILL_CONFIRMING: + 'A previous payment for this order is still being confirmed. Please wait a moment before trying again.', + PAYMENT_ACTION_REQUIRED: + 'Your payment needs additional verification. Please try again or choose another payment method.', TRANSACTION_PROCESSING_FAILED: 'Failed to process transaction', 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 95a556dac..1547c5ead 100644 --- a/packages/localizations/src/enUs.ts +++ b/packages/localizations/src/enUs.ts @@ -278,6 +278,10 @@ export const enUs = { DRAFT_ORDER_RETRIEVAL_FAILED: 'Failed to retrieve order', DATA_SOURCES_NOT_FOUND: 'Data sources not found', TRANSACTION_API_NOT_FOUND: 'Transaction API data source not found', + PAYMENT_STILL_CONFIRMING: + 'A previous payment for this order is still being confirmed. Please wait a moment before trying again.', + PAYMENT_ACTION_REQUIRED: + 'Your payment needs additional verification. Please try again or choose another payment method.', TRANSACTION_PROCESSING_FAILED: 'Failed to process transaction', 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 8128fee07..caf08ffd5 100644 --- a/packages/localizations/src/esAr.ts +++ b/packages/localizations/src/esAr.ts @@ -284,6 +284,10 @@ export const esAr = { DATA_SOURCES_NOT_FOUND: 'Fuentes de datos no encontradas', TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacciones no encontrada', + PAYMENT_STILL_CONFIRMING: + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento antes de volver a intentarlo.', + PAYMENT_ACTION_REQUIRED: + 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', 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 ccb43c9a2..6a7b96655 100644 --- a/packages/localizations/src/esCl.ts +++ b/packages/localizations/src/esCl.ts @@ -286,6 +286,10 @@ export const esCl = { DATA_SOURCES_NOT_FOUND: 'Fuentes de datos no encontradas', TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacción no encontrada', + PAYMENT_STILL_CONFIRMING: + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento antes de volver a intentarlo.', + PAYMENT_ACTION_REQUIRED: + 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', 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 b80b2f9ce..bb6134a81 100644 --- a/packages/localizations/src/esCo.ts +++ b/packages/localizations/src/esCo.ts @@ -284,6 +284,10 @@ export const esCo = { DATA_SOURCES_NOT_FOUND: 'Fuentes de datos no encontradas', TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacciones no encontrada', + PAYMENT_STILL_CONFIRMING: + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento antes de volver a intentarlo.', + PAYMENT_ACTION_REQUIRED: + 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', 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 013cf3a60..54f659a20 100644 --- a/packages/localizations/src/esEs.ts +++ b/packages/localizations/src/esEs.ts @@ -287,6 +287,10 @@ export const esEs = { DATA_SOURCES_NOT_FOUND: 'Fuentes de datos no encontradas', TRANSACTION_API_NOT_FOUND: 'Fuente de datos de la API de transacciones no encontrada', + PAYMENT_STILL_CONFIRMING: + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento antes de volver a intentarlo.', + PAYMENT_ACTION_REQUIRED: + 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'No se pudo procesar la transacción', 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 171dc3c84..1fb979606 100644 --- a/packages/localizations/src/esMx.ts +++ b/packages/localizations/src/esMx.ts @@ -284,6 +284,10 @@ export const esMx = { DATA_SOURCES_NOT_FOUND: 'Fuentes de datos no encontradas', TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacciones no encontrada', + PAYMENT_STILL_CONFIRMING: + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento antes de volver a intentarlo.', + PAYMENT_ACTION_REQUIRED: + 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', 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 3d1f4f36e..95c6442d1 100644 --- a/packages/localizations/src/esPe.ts +++ b/packages/localizations/src/esPe.ts @@ -284,6 +284,10 @@ export const esPe = { DATA_SOURCES_NOT_FOUND: 'Fuentes de datos no encontradas', TRANSACTION_API_NOT_FOUND: 'Fuente de datos de API de transacciones no encontrada', + PAYMENT_STILL_CONFIRMING: + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento antes de volver a intentarlo.', + PAYMENT_ACTION_REQUIRED: + 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', 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 fc77429b7..4d96df1be 100644 --- a/packages/localizations/src/esUs.ts +++ b/packages/localizations/src/esUs.ts @@ -284,6 +284,10 @@ export const esUs = { DATA_SOURCES_NOT_FOUND: 'Fuentes de datos no encontradas', TRANSACTION_API_NOT_FOUND: 'Fuente de datos de Transaction API no encontrada', + PAYMENT_STILL_CONFIRMING: + 'Todavía se está confirmando un pago anterior de este pedido. Espera un momento antes de volver a intentarlo.', + PAYMENT_ACTION_REQUIRED: + 'Tu pago requiere una verificación adicional. Inténtalo de nuevo o elige otro método de pago.', TRANSACTION_PROCESSING_FAILED: 'Error al procesar transacción', 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 4f4eed863..9dd386f8c 100644 --- a/packages/localizations/src/frCa.ts +++ b/packages/localizations/src/frCa.ts @@ -293,6 +293,10 @@ export const frCa = { DATA_SOURCES_NOT_FOUND: 'Sources de données introuvables', TRANSACTION_API_NOT_FOUND: 'Source de données API de transaction introuvable', + PAYMENT_STILL_CONFIRMING: + 'Un paiement précédent pour cette commande est toujours en cours de confirmation. Patientez un instant avant de réessayer.', + PAYMENT_ACTION_REQUIRED: + 'Votre paiement nécessite une vérification supplémentaire. Réessayez ou choisissez un autre moyen de paiement.', TRANSACTION_PROCESSING_FAILED: 'Échec du traitement de la transaction', 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 09d4c91a9..a78091bca 100644 --- a/packages/localizations/src/frFr.ts +++ b/packages/localizations/src/frFr.ts @@ -293,6 +293,10 @@ export const frFr = { DATA_SOURCES_NOT_FOUND: 'Sources de données non trouvées', TRANSACTION_API_NOT_FOUND: "Source de données de l'API de transaction non trouvée", + PAYMENT_STILL_CONFIRMING: + 'Un paiement précédent pour cette commande est toujours en cours de confirmation. Patientez un instant avant de réessayer.', + PAYMENT_ACTION_REQUIRED: + 'Votre paiement nécessite une vérification supplémentaire. Réessayez ou choisissez un autre moyen de paiement.', TRANSACTION_PROCESSING_FAILED: 'Échec du traitement de la transaction', 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 4bdc412ef..e2b8fb171 100644 --- a/packages/localizations/src/idId.ts +++ b/packages/localizations/src/idId.ts @@ -278,6 +278,10 @@ export const idId = { DRAFT_ORDER_RETRIEVAL_FAILED: 'Gagal mengambil pesanan', DATA_SOURCES_NOT_FOUND: 'Sumber data tidak ditemukan', TRANSACTION_API_NOT_FOUND: 'Sumber data API transaksi tidak ditemukan', + PAYMENT_STILL_CONFIRMING: + 'Pembayaran sebelumnya untuk pesanan ini masih dikonfirmasi. Tunggu sebentar sebelum mencoba lagi.', + 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 de2515f50..4622259a4 100644 --- a/packages/localizations/src/itIt.ts +++ b/packages/localizations/src/itIt.ts @@ -292,6 +292,10 @@ export const itIt = { DRAFT_ORDER_RETRIEVAL_FAILED: "Errore nel recupero dell'ordine", DATA_SOURCES_NOT_FOUND: 'Fonti dati non trovate', TRANSACTION_API_NOT_FOUND: 'Fonte dati API transazioni non trovata', + PAYMENT_STILL_CONFIRMING: + 'Un pagamento precedente per questo ordine è ancora in fase di conferma. Attendi un momento prima di riprovare.', + PAYMENT_ACTION_REQUIRED: + 'Il pagamento richiede una verifica aggiuntiva. Riprova o scegli un altro metodo di pagamento.', TRANSACTION_PROCESSING_FAILED: "Errore nell'elaborazione della transazione", 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 d47a9bba4..9da4c1395 100644 --- a/packages/localizations/src/ptBr.ts +++ b/packages/localizations/src/ptBr.ts @@ -282,6 +282,10 @@ export const ptBr = { DATA_SOURCES_NOT_FOUND: 'Fontes de dados não encontradas', TRANSACTION_API_NOT_FOUND: 'Fonte de dados da API de transação não encontrada', + PAYMENT_STILL_CONFIRMING: + 'Um pagamento anterior deste pedido ainda está sendo confirmado. Aguarde um momento antes de tentar novamente.', + PAYMENT_ACTION_REQUIRED: + 'Seu pagamento precisa de uma verificação adicional. Tente novamente ou escolha outra forma de pagamento.', TRANSACTION_PROCESSING_FAILED: 'Falha ao processar transação', 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 208d5935b..1c1852d1c 100644 --- a/packages/localizations/src/qaPs.ts +++ b/packages/localizations/src/qaPs.ts @@ -286,6 +286,10 @@ export const qaPs = { DRAFT_ORDER_RETRIEVAL_FAILED: '[Fâîlëd ţö rëţrîëvë ördër]', DATA_SOURCES_NOT_FOUND: '[Dâţâ šöürçëš ñöţ föüñd îñ šÿšţëm]', TRANSACTION_API_NOT_FOUND: '[Ţrâñšâçţîöñ ÂÞÎ dâţâ šöürçë ñöţ föüñd]', + PAYMENT_STILL_CONFIRMING: + '[A previous payment for this order is still being confirmed. Please wait a moment before trying again.]', + PAYMENT_ACTION_REQUIRED: + '[Your payment needs additional verification. Please try again or choose another payment method.]', TRANSACTION_PROCESSING_FAILED: '[Fâîlëd ţö þröçëšš ţrâñšâçţîöñ]', 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 daa63b5e1..5b67c353e 100644 --- a/packages/localizations/src/trTr.ts +++ b/packages/localizations/src/trTr.ts @@ -280,6 +280,10 @@ export const trTr = { DRAFT_ORDER_RETRIEVAL_FAILED: 'Sipariş alınamadı', DATA_SOURCES_NOT_FOUND: 'Veri kaynakları bulunamadı', TRANSACTION_API_NOT_FOUND: 'İşlem API veri kaynağı bulunamadı', + PAYMENT_STILL_CONFIRMING: + 'Bu sipariş için önceki bir ödeme hâlâ onaylanıyor. Lütfen tekrar denemeden önce biraz bekleyin.', + PAYMENT_ACTION_REQUIRED: + 'Ödemeniz için ek doğrulama gerekiyor. Lütfen tekrar deneyin veya başka bir ödeme yöntemi seçin.', TRANSACTION_PROCESSING_FAILED: 'İşlem işlenemedi', 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 37ff3b91f..a5a251b06 100644 --- a/packages/localizations/src/viVn.ts +++ b/packages/localizations/src/viVn.ts @@ -278,6 +278,10 @@ export const viVn = { DRAFT_ORDER_RETRIEVAL_FAILED: 'Không thể truy xuất đơn hàng', DATA_SOURCES_NOT_FOUND: 'Không tìm thấy nguồn dữ liệu', TRANSACTION_API_NOT_FOUND: 'Không tìm thấy nguồn dữ liệu API giao dịch', + PAYMENT_STILL_CONFIRMING: + 'Một khoản thanh toán trước đó cho đơn hàng này vẫn đang được xác nhận. Vui lòng đợi một chút trước khi thử lại.', + PAYMENT_ACTION_REQUIRED: + 'Thanh toán của bạn cần được xác minh thêm. Vui lòng thử lại hoặc chọn phương thức thanh toán khác.', TRANSACTION_PROCESSING_FAILED: 'Không thể xử lý giao dịch', 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 19eb5bf9d..2823a8f1c 100644 --- a/packages/localizations/src/zhCn.ts +++ b/packages/localizations/src/zhCn.ts @@ -270,6 +270,9 @@ export const zhCn = { DRAFT_ORDER_RETRIEVAL_FAILED: '获取订单失败', DATA_SOURCES_NOT_FOUND: '数据源未找到', TRANSACTION_API_NOT_FOUND: '交易 API 数据源未找到', + PAYMENT_STILL_CONFIRMING: + '此订单的上一笔付款仍在确认中。请稍候再试。', + PAYMENT_ACTION_REQUIRED: '您的付款需要额外验证。请重试或选择其他付款方式。', TRANSACTION_PROCESSING_FAILED: '处理交易失败', TRANSACTION_CREATION_FAILED: '查找或创建交易失败', ORDER_OPENING_FAILED: '打开订单失败', diff --git a/packages/localizations/src/zhSg.ts b/packages/localizations/src/zhSg.ts index 52f1938b2..4e331bae3 100644 --- a/packages/localizations/src/zhSg.ts +++ b/packages/localizations/src/zhSg.ts @@ -270,6 +270,9 @@ export const zhSg = { DRAFT_ORDER_RETRIEVAL_FAILED: '检索订单失败', DATA_SOURCES_NOT_FOUND: '数据源未找到', TRANSACTION_API_NOT_FOUND: '交易API数据源未找到', + PAYMENT_STILL_CONFIRMING: + '此订单的上一笔付款仍在确认中。请稍候再试。', + PAYMENT_ACTION_REQUIRED: '您的付款需要额外验证。请重试或选择其他付款方式。', TRANSACTION_PROCESSING_FAILED: '处理交易失败', TRANSACTION_CREATION_FAILED: '查找或创建交易失败', 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 1724ab89b..c14c7c0e7 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,8 @@ import { PaymentProvider, useConfirmCheckout, } from '@/components/checkout/payment/utils/use-confirm-checkout'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { confirmCheckout } from '@/lib/godaddy/godaddy'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { buildCheckoutSession, @@ -19,9 +21,11 @@ import { clearOperations, getOperations, mockGodaddyApi, + mockWindowLocation, type RenderCheckoutOptions, renderCheckout, setApiError, + setCurrentDraftOrder, waitForCheckoutReady, waitForOperation, } from './checkout-test-env'; @@ -98,7 +102,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 +116,7 @@ function ConfirmSeamButton({ form.setValue(key, value); } } - void confirmCheckout + void confirmation .mutateAsync({ paymentToken: isExpress ? 'express-nonce' : '', paymentType, @@ -140,7 +144,7 @@ function ConfirmSeamButton({ } function DuplicateConfirmSeamButton() { - const confirmCheckout = useConfirmCheckout(); + const confirmation = useConfirmCheckout(); const form = useFormContext(); const [secondResult, setSecondResult] = useState('idle'); @@ -156,8 +160,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 +350,69 @@ describe('Checkout confirm errors', () => { expect(window.location.href).not.toContain('should-not-go-here'); }); + it('blocks every provider while another flow has an unresolved payment', async () => { + const { session, queryClient, user } = renderCheckoutWithConfirmSeam({ + sessionOverrides: offlineSessionOverrides(), + }); + await waitForCheckoutReady(); + // A card 3-D Secure payment whose final confirmation had an unclear outcome. + queryClient.setQueryData(checkoutQueryKeys.unresolvedPayment(session.id), { + flow: 'card', + paymentReference: 'pi_unresolved', + }); + vi.mocked(confirmCheckout).mockClear(); + + await user.click( + await screen.findByRole('button', { name: /confirm seam/i }) + ); + + expect( + await screen.findByText(/still being confirmed/i) + ).toBeInTheDocument(); + expect(confirmCheckout).not.toHaveBeenCalled(); + // Checkout stays usable: nothing is left locked. + expect(screen.getByRole('button', { name: /confirm seam/i })).toBeEnabled(); + }); + + it('refreshes after a lost confirmation response and redirects the paid order', async () => { + mockWindowLocation(); + const successUrl = 'https://merchant.example/success'; + 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 41564091c..e03ed9ef6 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 000000000..2d1bc6842 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-paid-order.test.tsx @@ -0,0 +1,233 @@ +import { act, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { + buildDraftOrder, + getOperations, + mockWindowLocation, + renderCheckout, + setCurrentDraftOrder, + waitForCheckoutReady, +} from './checkout-test-env'; + +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('looks up the status again instead of acting on one cached before the order was paid', async () => { + const successUrl = 'https://merchant.example/success'; + const returnUrl = 'https://merchant.example/cart'; + const { queryClient, session } = renderCheckout({ + sessionOverrides: { successUrl, returnUrl }, + draftOrderOverrides: { statuses: { paymentStatus: 'UNPAID' } }, + }); + await waitForCheckoutReady(); + // Recovery after a lost confirmation response cached UNPAID before the + // payment was recorded. + act(() => { + queryClient.setQueryData(checkoutQueryKeys.orderStatus(session.id), { + checkoutSession: { + id: session.id, + orderStatus: { status: 'DRAFT', paymentStatus: 'UNPAID' }, + }, + }); + }); + // The payment is recorded, so the draft order is no longer returned. + setCurrentDraftOrder( + buildDraftOrder({ statuses: { status: 'OPEN', paymentStatus: 'PAID' } }) + ); + await act(async () => { + await queryClient.refetchQueries({ + queryKey: checkoutQueryKeys.draftOrder(session.id), + }); + }); + + expect(await screen.findByRole('status')).toHaveTextContent( + 'Payment successful' + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(1100); + }); + expect(window.location.href).toBe(successUrl); + expect(getOperations('CheckoutOrderStatus').length).toBeGreaterThan(0); + }); + + it('redirects when a refreshed order status becomes paid', async () => { + const successUrl = 'https://merchant.example/success'; + const { queryClient, session } = renderCheckout({ + 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 000000000..aaf961864 --- /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 dc86dd3f0..9a9e0c08e 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 000000000..d4e07872c --- /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 52f4c4836..80c2a638c 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 2176dd76f..512808451 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 000000000..f5fe7a58d --- /dev/null +++ b/packages/react/src/components/checkout/order/use-checkout-order-status.ts @@ -0,0 +1,48 @@ +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, + // Always look the status up again when it's needed: a status cached before + // the draft order went missing (for example UNPAID, from the recovery after + // a lost confirmation) can't explain why it's missing now. + staleTime: 0, + select: data => data.checkoutSession?.orderStatus ?? null, + // An API without orderStatus fails validation; retrying cannot help, and a + // failed lookup only means checkout is not known to be complete. + 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 000000000..8fae1a759 --- /dev/null +++ b/packages/react/src/components/checkout/order/use-paid-order-redirect.ts @@ -0,0 +1,44 @@ +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 { isFetching, isPending, isError } = orderStatusQuery; + + const showPaidOrder = + isCheckoutComplete(orderStatusQuery.data) && !isConfirmingCheckout; + + useEffect(() => { + if (showPaidOrder) redirectToSuccessUrl(session?.successUrl); + }, [showPaidOrder, session?.successUrl]); + + return { + showPaidOrder, + // Loading until the lookup started when the draft order went missing + // settles, so the missing-order fallback can't act on an older cached + // status (useCheckoutOrderStatus treats every cached status as stale). A + // failed lookup (an API without orderStatus) settles it. + isLoadingOrderStatus: + needsOrderStatus && (isFetching || (isPending && !isError)), + }; +} diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx index 18269a771..9ce5bc716 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() {