From 91e88f56a8936fac0a7b9c51f91e3567278803ff Mon Sep 17 00:00:00 2001 From: pchawla-godaddy Date: Wed, 9 Sep 2026 12:37:16 +0530 Subject: [PATCH 01/12] Add Razorpay as a standard checkout payment method. Wire the C2 checkout UI to authorize, open Razorpay Standard Checkout, encode the signed callback, and confirm, matching the checkout-api contract. Co-authored-by: Cursor --- .changeset/bright-cats-pay.md | 6 + examples/nextjs/app/checkout.tsx | 10 +- examples/nextjs/env.sample | 3 + packages/localizations/src/deDe.ts | 2 + packages/localizations/src/enAu.ts | 2 + packages/localizations/src/enIe.ts | 2 + packages/localizations/src/enUs.ts | 2 + packages/localizations/src/esAr.ts | 2 + packages/localizations/src/esCl.ts | 2 + packages/localizations/src/esCo.ts | 2 + packages/localizations/src/esEs.ts | 2 + packages/localizations/src/esMx.ts | 2 + packages/localizations/src/esPe.ts | 2 + packages/localizations/src/esUs.ts | 2 + packages/localizations/src/frCa.ts | 2 + packages/localizations/src/frFr.ts | 2 + packages/localizations/src/idId.ts | 2 + packages/localizations/src/itIt.ts | 2 + packages/localizations/src/ptBr.ts | 2 + packages/localizations/src/qaPs.ts | 2 + packages/localizations/src/trTr.ts | 2 + packages/localizations/src/viVn.ts | 2 + packages/localizations/src/zhCn.ts | 2 + packages/localizations/src/zhSg.ts | 2 + .../__tests__/checkout-razorpay.test.tsx | 41 +++ .../checkout/__tests__/checkout-test-env.tsx | 9 + .../src/components/checkout/checkout.tsx | 8 + .../razorpay/razorpay.test.tsx | 227 +++++++++++++++ .../checkout-buttons/razorpay/razorpay.tsx | 261 ++++++++++++++++++ .../checkout/payment/icons/Razorpay.tsx | 19 ++ .../checkout/payment/lazy-payment-loader.tsx | 15 + .../checkout/payment/payment-form.tsx | 32 ++- .../utils/razorpay-payment-token.test.ts | 24 ++ .../payment/utils/razorpay-payment-token.ts | 21 ++ .../payment/utils/use-confirm-checkout.ts | 1 + .../utils/use-get-selected-payment-method.ts | 12 +- .../payment/utils/use-load-razorpay.test.tsx | 41 +++ .../payment/utils/use-load-razorpay.ts | 90 ++++++ .../utils/checkout-transformers.test.ts | 21 ++ .../checkout/utils/checkout-transformers.ts | 28 ++ .../react/src/lib/godaddy/checkout-env.ts | 16 ++ .../react/src/lib/godaddy/checkout-queries.ts | 4 + packages/react/src/types.ts | 2 + 43 files changed, 921 insertions(+), 12 deletions(-) create mode 100644 .changeset/bright-cats-pay.md create mode 100644 packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx create mode 100644 packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx create mode 100644 packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx create mode 100644 packages/react/src/components/checkout/payment/icons/Razorpay.tsx create mode 100644 packages/react/src/components/checkout/payment/utils/razorpay-payment-token.test.ts create mode 100644 packages/react/src/components/checkout/payment/utils/razorpay-payment-token.ts create mode 100644 packages/react/src/components/checkout/payment/utils/use-load-razorpay.test.tsx create mode 100644 packages/react/src/components/checkout/payment/utils/use-load-razorpay.ts diff --git a/.changeset/bright-cats-pay.md b/.changeset/bright-cats-pay.md new file mode 100644 index 00000000..d8b2f932 --- /dev/null +++ b/.changeset/bright-cats-pay.md @@ -0,0 +1,6 @@ +--- +'@godaddy/react': minor +'@godaddy/localizations': patch +--- + +Add Razorpay as a standard checkout payment method. diff --git a/examples/nextjs/app/checkout.tsx b/examples/nextjs/app/checkout.tsx index 3478e88a..7166e208 100644 --- a/examples/nextjs/app/checkout.tsx +++ b/examples/nextjs/app/checkout.tsx @@ -63,8 +63,14 @@ export function CheckoutPage({ session }: { session: CheckoutSession }) { ccavenueConfig={ process.env.NEXT_PUBLIC_CCAVENUE_ACCESS_CODE_ID ? { - accessCodeId: - process.env.NEXT_PUBLIC_CCAVENUE_ACCESS_CODE_ID, + accessCodeId: process.env.NEXT_PUBLIC_CCAVENUE_ACCESS_CODE_ID, + } + : undefined + } + razorpayConfig={ + process.env.NEXT_PUBLIC_RAZORPAY_PUBLIC_TOKEN + ? { + publicToken: process.env.NEXT_PUBLIC_RAZORPAY_PUBLIC_TOKEN, } : undefined } diff --git a/examples/nextjs/env.sample b/examples/nextjs/env.sample index abd13fa6..60c8b592 100644 --- a/examples/nextjs/env.sample +++ b/examples/nextjs/env.sample @@ -25,3 +25,6 @@ NEXT_PUBLIC_PAYPAL_CLIENT_ID= # MercadoPago Credentials NEXT_PUBLIC_MERCADOPAGO_PUBLIC_KEY= NEXT_PUBLIC_MERCADOPAGO_COUNTRY=AR + +# Razorpay OAuth Public Token (safe for client-side Checkout initialization) +NEXT_PUBLIC_RAZORPAY_PUBLIC_TOKEN= diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts index 1842a654..7f1cd814 100644 --- a/packages/localizations/src/deDe.ts +++ b/packages/localizations/src/deDe.ts @@ -112,6 +112,7 @@ export const deDe = { offline: 'Offline-Zahlungen', mercadopago: 'Mercado Pago', ccavenue: 'Mit CCAvenue bezahlen', + razorpay: 'Razorpay', ach: 'Bankkonto', }, descriptions: { @@ -125,6 +126,7 @@ export const deDe = { 'Verwende das MercadoPago-Formular unten, um deinen Kauf sicher abzuschließen.', ach: '', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'Keine Zahlungsmethoden verfügbar', cardNumber: 'Kartennummer', diff --git a/packages/localizations/src/enAu.ts b/packages/localizations/src/enAu.ts index 7c8eb760..04c334f5 100644 --- a/packages/localizations/src/enAu.ts +++ b/packages/localizations/src/enAu.ts @@ -113,6 +113,7 @@ export const enAu = { ach: 'Bank Account', mercadopago: 'Mercado Pago', ccavenue: 'Pay with CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -125,6 +126,7 @@ export const enAu = { mercadopago: 'Use the MercadoPago form below to complete your purchase securely.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'No payment methods available', cardNumber: 'Card number', diff --git a/packages/localizations/src/enIe.ts b/packages/localizations/src/enIe.ts index e87a00a5..b2d8fb93 100644 --- a/packages/localizations/src/enIe.ts +++ b/packages/localizations/src/enIe.ts @@ -113,6 +113,7 @@ export const enIe = { ach: 'Bank Account', mercadopago: 'Mercado Pago', ccavenue: 'Pay with CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -125,6 +126,7 @@ export const enIe = { mercadopago: 'Use the MercadoPago form below to complete your purchase securely.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'No payment methods available', cardNumber: 'Card number', diff --git a/packages/localizations/src/enUs.ts b/packages/localizations/src/enUs.ts index ffef4e36..eb107135 100644 --- a/packages/localizations/src/enUs.ts +++ b/packages/localizations/src/enUs.ts @@ -113,6 +113,7 @@ export const enUs = { ach: 'Bank Account', mercadopago: 'Mercado Pago', ccavenue: 'Pay with CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -125,6 +126,7 @@ export const enUs = { mercadopago: 'Use the MercadoPago form below to complete your purchase securely.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'No payment methods available', cardNumber: 'Card number', diff --git a/packages/localizations/src/esAr.ts b/packages/localizations/src/esAr.ts index cf8a2e19..85e9cb14 100644 --- a/packages/localizations/src/esAr.ts +++ b/packages/localizations/src/esAr.ts @@ -114,6 +114,7 @@ export const esAr = { ach: 'Cuenta Bancaria', mercadopago: 'Mercado Pago', ccavenue: 'الدفع عبر CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -126,6 +127,7 @@ export const esAr = { mercadopago: 'Usa el formulario de MercadoPago a continuación para completar tu compra de forma segura.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'No hay métodos de pago disponibles', cardNumber: 'Número de tarjeta', diff --git a/packages/localizations/src/esCl.ts b/packages/localizations/src/esCl.ts index 72b4ccc5..6635c421 100644 --- a/packages/localizations/src/esCl.ts +++ b/packages/localizations/src/esCl.ts @@ -114,6 +114,7 @@ export const esCl = { ach: 'Cuenta Bancaria', mercadopago: 'Mercado Pago', ccavenue: 'Pagar con CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -126,6 +127,7 @@ export const esCl = { mercadopago: 'Usa el formulario de MercadoPago a continuación para completar tu compra de forma segura.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'No hay métodos de pago disponibles', cardNumber: 'Número de tarjeta', diff --git a/packages/localizations/src/esCo.ts b/packages/localizations/src/esCo.ts index e78cdae5..dc7a9b2d 100644 --- a/packages/localizations/src/esCo.ts +++ b/packages/localizations/src/esCo.ts @@ -114,6 +114,7 @@ export const esCo = { ach: 'Cuenta bancaria', mercadopago: 'Mercado Pago', ccavenue: 'Pagar con CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -126,6 +127,7 @@ export const esCo = { mercadopago: 'Usa el formulario de MercadoPago a continuación para completar tu compra de forma segura.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'No hay métodos de pago disponibles', cardNumber: 'Número de tarjeta', diff --git a/packages/localizations/src/esEs.ts b/packages/localizations/src/esEs.ts index 34f1005d..9f96a84b 100644 --- a/packages/localizations/src/esEs.ts +++ b/packages/localizations/src/esEs.ts @@ -114,6 +114,7 @@ export const esEs = { ach: 'Cuenta bancaria', mercadopago: 'Mercado Pago', ccavenue: 'Pagar con CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -126,6 +127,7 @@ export const esEs = { mercadopago: 'Usa el formulario de MercadoPago a continuación para completar tu compra de forma segura.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'No hay métodos de pago disponibles', cardNumber: 'Número de tarjeta', diff --git a/packages/localizations/src/esMx.ts b/packages/localizations/src/esMx.ts index 94447505..27591588 100644 --- a/packages/localizations/src/esMx.ts +++ b/packages/localizations/src/esMx.ts @@ -114,6 +114,7 @@ export const esMx = { ach: 'Cuenta Bancaria', mercadopago: 'Mercado Pago', ccavenue: 'Pagar con CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -126,6 +127,7 @@ export const esMx = { mercadopago: 'Usa el formulario de MercadoPago a continuación para completar tu compra de forma segura.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'No hay métodos de pago disponibles', cardNumber: 'Número de tarjeta', diff --git a/packages/localizations/src/esPe.ts b/packages/localizations/src/esPe.ts index b5145772..0a9fe583 100644 --- a/packages/localizations/src/esPe.ts +++ b/packages/localizations/src/esPe.ts @@ -114,6 +114,7 @@ export const esPe = { ach: 'Cuenta Bancaria', mercadopago: 'Mercado Pago', ccavenue: 'Pagar con CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -126,6 +127,7 @@ export const esPe = { mercadopago: 'Usa el formulario de MercadoPago a continuación para completar tu compra de forma segura.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'No hay métodos de pago disponibles', cardNumber: 'Número de tarjeta', diff --git a/packages/localizations/src/esUs.ts b/packages/localizations/src/esUs.ts index 4d0089e1..292a1ec5 100644 --- a/packages/localizations/src/esUs.ts +++ b/packages/localizations/src/esUs.ts @@ -114,6 +114,7 @@ export const esUs = { ach: 'Cuenta Bancaria', mercadopago: 'Mercado Pago', ccavenue: 'Pagar con CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -126,6 +127,7 @@ export const esUs = { mercadopago: 'Usa el formulario de MercadoPago a continuación para completar tu compra de forma segura.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'No hay métodos de pago disponibles', cardNumber: 'Número de tarjeta', diff --git a/packages/localizations/src/frCa.ts b/packages/localizations/src/frCa.ts index 99c4888b..b6251079 100644 --- a/packages/localizations/src/frCa.ts +++ b/packages/localizations/src/frCa.ts @@ -114,6 +114,7 @@ export const frCa = { ach: 'Compte bancaire', mercadopago: 'Mercado Pago', ccavenue: 'Payer avec CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -126,6 +127,7 @@ export const frCa = { mercadopago: 'Utilisez le formulaire MercadoPago ci-dessous pour finaliser votre achat en toute sécurité.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'Aucune méthode de paiement disponible', cardNumber: 'Numéro de carte', diff --git a/packages/localizations/src/frFr.ts b/packages/localizations/src/frFr.ts index c8345087..b54f62fb 100644 --- a/packages/localizations/src/frFr.ts +++ b/packages/localizations/src/frFr.ts @@ -114,6 +114,7 @@ export const frFr = { ach: 'Compte bancaire', mercadopago: 'Mercado Pago', ccavenue: 'Payer avec CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -126,6 +127,7 @@ export const frFr = { mercadopago: 'Utilisez le formulaire MercadoPago ci-dessous pour finaliser votre achat en toute sécurité.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'Aucune méthode de paiement disponible', cardNumber: 'Numéro de carte', diff --git a/packages/localizations/src/idId.ts b/packages/localizations/src/idId.ts index 41c794e4..7528b209 100644 --- a/packages/localizations/src/idId.ts +++ b/packages/localizations/src/idId.ts @@ -113,6 +113,7 @@ export const idId = { ach: 'Rekening Bank', mercadopago: 'Mercado Pago', ccavenue: 'Bayar dengan CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -125,6 +126,7 @@ export const idId = { mercadopago: 'Gunakan formulir MercadoPago di bawah untuk menyelesaikan pembelian Anda dengan aman.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'Tidak ada metode pembayaran tersedia', cardNumber: 'Nomor kartu', diff --git a/packages/localizations/src/itIt.ts b/packages/localizations/src/itIt.ts index 89525d46..7276992e 100644 --- a/packages/localizations/src/itIt.ts +++ b/packages/localizations/src/itIt.ts @@ -114,6 +114,7 @@ export const itIt = { ach: 'Conto Bancario', mercadopago: 'Mercado Pago', ccavenue: 'Paga con CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -126,6 +127,7 @@ export const itIt = { mercadopago: 'Usa il modulo MercadoPago qui sotto per completare l’acquisto in modo sicuro.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'Nessun metodo di pagamento disponibile', cardNumber: 'Numero della carta', diff --git a/packages/localizations/src/ptBr.ts b/packages/localizations/src/ptBr.ts index ae23da6e..7a87a83c 100644 --- a/packages/localizations/src/ptBr.ts +++ b/packages/localizations/src/ptBr.ts @@ -113,6 +113,7 @@ export const ptBr = { ach: 'Conta Bancária', mercadopago: 'Mercado Pago', ccavenue: 'Pagar com CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -125,6 +126,7 @@ export const ptBr = { mercadopago: 'Use o formulário do MercadoPago abaixo para concluir sua compra com segurança.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'Nenhum método de pagamento disponível', cardNumber: 'Número do cartão', diff --git a/packages/localizations/src/qaPs.ts b/packages/localizations/src/qaPs.ts index 3f72740e..36c9b676 100644 --- a/packages/localizations/src/qaPs.ts +++ b/packages/localizations/src/qaPs.ts @@ -114,6 +114,7 @@ export const qaPs = { ach: '[Bâñk Âççöüñţ Þâÿmëñţ]', mercadopago: 'Mercado Pago', ccavenue: '[Þâÿ ïñ ÇÇÂvëñûë]', + razorpay: '[Râžörþâÿ]', }, descriptions: { creditCard: '', @@ -126,6 +127,7 @@ export const qaPs = { mercadopago: '[Üšë ţhë MërçâðöÞâgö förm këlöw ţö çömþlëţë ÿöür þürçhâšë šëçürëlÿ.]', ccavenue: '', + razorpay: '', }, noMethodsAvailable: '[Ñö þâÿmëñţ mëţhödš âvâîlâblë âţ ţhîš ţîmë]', cardNumber: '[Çârd ñümkër îñþüţ fîëld]', diff --git a/packages/localizations/src/trTr.ts b/packages/localizations/src/trTr.ts index 7854783b..a35405bd 100644 --- a/packages/localizations/src/trTr.ts +++ b/packages/localizations/src/trTr.ts @@ -113,6 +113,7 @@ export const trTr = { ach: 'Banka Hesabı', mercadopago: 'Mercado Pago', ccavenue: 'CCAvenue ile öde', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -125,6 +126,7 @@ export const trTr = { mercadopago: 'Satın alımınızı güvenle tamamlamak için aşağıdaki MercadoPago formunu kullanın.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'Kullanılabilir ödeme yöntemi yok', cardNumber: 'Kart numarası', diff --git a/packages/localizations/src/viVn.ts b/packages/localizations/src/viVn.ts index d829fe58..98e0607e 100644 --- a/packages/localizations/src/viVn.ts +++ b/packages/localizations/src/viVn.ts @@ -113,6 +113,7 @@ export const viVn = { ach: 'Tài khoản ngân hàng', mercadopago: 'Mercado Pago', ccavenue: 'Thanh toán bằng CCAvenue', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -125,6 +126,7 @@ export const viVn = { mercadopago: 'Hãy sử dụng biểu mẫu MercadoPago bên dưới để hoàn tất mua hàng một cách an toàn.', ccavenue: '', + razorpay: '', }, noMethodsAvailable: 'Không có phương thức thanh toán nào', cardNumber: 'Số thẻ', diff --git a/packages/localizations/src/zhCn.ts b/packages/localizations/src/zhCn.ts index 229f9bb8..53a9a3fc 100644 --- a/packages/localizations/src/zhCn.ts +++ b/packages/localizations/src/zhCn.ts @@ -109,6 +109,7 @@ export const zhCn = { ach: '银行账户', mercadopago: 'Mercado Pago', ccavenue: '使用 CCAvenue 支付', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -120,6 +121,7 @@ export const zhCn = { ach: '', mercadopago: '请使用下方的 MercadoPago 表单安全完成购买。', ccavenue: '', + razorpay: '', }, noMethodsAvailable: '暂无可用的付款方式', cardNumber: '卡号', diff --git a/packages/localizations/src/zhSg.ts b/packages/localizations/src/zhSg.ts index d380a7a1..f0a424f6 100644 --- a/packages/localizations/src/zhSg.ts +++ b/packages/localizations/src/zhSg.ts @@ -109,6 +109,7 @@ export const zhSg = { ach: '银行账户', mercadopago: 'Mercado Pago', ccavenue: '使用 CCAvenue 支付', + razorpay: 'Razorpay', }, descriptions: { creditCard: '', @@ -120,6 +121,7 @@ export const zhSg = { ach: '', mercadopago: '请使用下方的 MercadoPago 表单安全完成购买。', ccavenue: '', + razorpay: '', }, noMethodsAvailable: '无可用付款方式', cardNumber: '卡号', diff --git a/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx new file mode 100644 index 00000000..b8837c0c --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx @@ -0,0 +1,41 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { + buildCheckoutSession, + renderCheckout, + waitForCheckoutReady, +} from './checkout-test-env'; + +function buildRazorpaySession() { + const session = buildCheckoutSession(); + session.paymentMethods = { + razorpay: { + type: 'razorpay', + processor: 'razorpay', + checkoutTypes: ['standard'], + }, + } as never; + return session; +} + +describe('Razorpay payment method', () => { + it('renders the Razorpay checkout button when session and public token are configured', async () => { + renderCheckout({ + session: buildRazorpaySession(), + checkoutProps: { + razorpayConfig: { publicToken: 'rzp_test_public' }, + }, + }); + await waitForCheckoutReady(); + + expect(await screen.findByTestId('mock-razorpay-button')).toBeVisible(); + }); + + it('hides Razorpay when the public token is unavailable', async () => { + renderCheckout({ session: buildRazorpaySession() }); + await waitForCheckoutReady(); + + expect(screen.getByText('No payment methods available')).toBeVisible(); + expect(screen.queryByTestId('mock-razorpay-button')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-test-env.tsx b/packages/react/src/components/checkout/__tests__/checkout-test-env.tsx index 91d9c2a7..1c5dd337 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-test-env.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-test-env.tsx @@ -91,6 +91,15 @@ vi.mock( }) ); +vi.mock( + '@/components/checkout/payment/checkout-buttons/razorpay/razorpay', + inertButtonMock({ + exportName: 'RazorpayCheckoutButton', + testId: 'mock-razorpay-button', + label: 'Pay with Razorpay', + }) +); + // The dedicated express buttons (paymentMethods.express.processor=godaddy / // stripe). Their real implementations open a wallet sheet, run their own // shipping/tax/coupon flow, and then call confirmCheckout with isExpress=true diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index bf532b01..aac029e0 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -94,6 +94,10 @@ export type CCAvenueConfig = { accessCodeId: string; }; +export type RazorpayConfig = { + publicToken: string; +}; + interface CheckoutContextValue { elements?: CheckoutElements; targets?: Partial< @@ -108,6 +112,7 @@ interface CheckoutContextValue { paypalConfig?: PayPalConfig; mercadoPagoConfig?: MercadoPagoConfig; ccavenueConfig?: CCAvenueConfig; + razorpayConfig?: RazorpayConfig; isConfirmingCheckout: boolean; setIsConfirmingCheckout: (isConfirming: boolean) => void; checkoutErrors?: string[] | undefined; @@ -214,6 +219,7 @@ export interface CheckoutProps { paypalConfig?: PayPalConfig; mercadoPagoConfig?: MercadoPagoConfig; ccavenueConfig?: CCAvenueConfig; + razorpayConfig?: RazorpayConfig; layout?: LayoutSection[]; direction?: 'ltr' | 'rtl'; showStoreHours?: boolean; @@ -239,6 +245,7 @@ export function Checkout(props: CheckoutProps) { paypalConfig, mercadoPagoConfig, ccavenueConfig, + razorpayConfig, isCheckoutDisabled, } = props; @@ -333,6 +340,7 @@ export function Checkout(props: CheckoutProps) { mercadoPagoConfig, paypalConfig, ccavenueConfig, + razorpayConfig, requiredFields, isConfirmingCheckout, setIsConfirmingCheckout, diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx new file mode 100644 index 00000000..9e8f0790 --- /dev/null +++ b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx @@ -0,0 +1,227 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + RAZORPAY_CALLBACK_TIMEOUT_MS, + RazorpayCheckoutButton, +} from './razorpay'; + +const mocks = vi.hoisted(() => ({ + authorize: vi.fn(), + confirm: vi.fn(), + flush: vi.fn(), + setCheckoutErrors: vi.fn(), + trigger: vi.fn(), + setFocus: vi.fn(), + getValues: vi.fn(), +})); + +vi.mock('@/components/checkout/checkout', () => ({ + useCheckoutContext: () => ({ + razorpayConfig: { publicToken: 'rzp_test_public' }, + session: { storeName: 'Test Store' }, + setCheckoutErrors: mocks.setCheckoutErrors, + isConfirmingCheckout: false, + }), +})); + +vi.mock( + '@/components/checkout/payment/utils/use-authorize-checkout', + () => ({ + useAuthorizeCheckout: () => ({ + mutateAsync: mocks.authorize, + isPending: false, + }), + }) +); + +vi.mock('@/components/checkout/payment/utils/use-confirm-checkout', () => ({ + PaymentProvider: { RAZORPAY: 'RAZORPAY' }, + useConfirmCheckout: () => ({ mutateAsync: mocks.confirm }), +})); + +vi.mock( + '@/components/checkout/payment/utils/use-flush-checkout-sync', + () => ({ + useFlushCheckoutSync: () => mocks.flush, + }) +); + +vi.mock( + '@/components/checkout/payment/utils/use-is-payment-disabled', + () => ({ + useIsPaymentDisabled: () => false, + }) +); + +vi.mock('@/components/checkout/payment/utils/use-load-razorpay', () => ({ + useLoadRazorpay: () => ({ + isRazorpayLoaded: true, + isRazorpayLoadFailed: false, + }), +})); + +vi.mock('react-hook-form', () => ({ + useFormContext: () => ({ + trigger: mocks.trigger, + setFocus: mocks.setFocus, + getValues: mocks.getValues, + formState: { errors: {} }, + }), +})); + +type CapturedOptions = { + key: string; + amount: number; + currency: string; + name?: string; + description: string; + order_id: string; + prefill?: { name?: string; email?: string; contact?: string }; + handler: (response: { + razorpay_payment_id?: string; + razorpay_order_id?: string; + razorpay_signature?: string; + }) => void; +}; + +describe('RazorpayCheckoutButton', () => { + let capturedOptions: CapturedOptions | undefined; + let paymentFailedHandler: (() => void) | undefined; + const open = vi.fn(); + const close = vi.fn(); + const on = vi.fn((event: string, handler: () => void) => { + if (event === 'payment.failed') paymentFailedHandler = handler; + }); + + beforeEach(() => { + vi.clearAllMocks(); + capturedOptions = undefined; + paymentFailedHandler = undefined; + mocks.trigger.mockResolvedValue(true); + mocks.flush.mockResolvedValue({ + latestOrder: { + id: 'draft-order-1', + totals: { total: { value: 2500, currencyCode: 'INR' } }, + billing: { + firstName: 'Test', + lastName: 'Buyer', + email: 'buyer@example.com', + phone: '(201) 555-0123', + address: { countryCode: 'US' }, + }, + }, + }); + mocks.authorize.mockResolvedValue({ + transactionRefNum: 'order_razorpay_123', + }); + mocks.confirm.mockResolvedValue(undefined); + + Object.defineProperty(window, 'Razorpay', { + configurable: true, + value: class { + constructor(options: CapturedOptions) { + capturedOptions = options; + } + + open = open; + close = close; + on = on; + }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('authorizes an order, opens Checkout, and confirms the signed handler payload', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Pay now' })); + + await waitFor(() => { + expect(open).toHaveBeenCalledOnce(); + }); + + expect(mocks.flush).toHaveBeenCalledWith({ + includeCurrentFormDiff: true, + }); + expect(mocks.authorize).toHaveBeenCalledWith({ + paymentType: 'razorpay', + paymentProvider: 'RAZORPAY', + }); + expect(capturedOptions).toMatchObject({ + key: 'rzp_test_public', + amount: 2500, + currency: 'INR', + name: 'Test Store', + description: 'draft-order-1', + order_id: 'order_razorpay_123', + prefill: { + name: 'Test Buyer', + email: 'buyer@example.com', + contact: '+12015550123', + }, + }); + expect(on).toHaveBeenCalledWith('payment.failed', expect.any(Function)); + + capturedOptions?.handler({ + razorpay_payment_id: 'pay_razorpay_456', + razorpay_order_id: 'order_razorpay_123', + razorpay_signature: 'signature_789', + }); + + await waitFor(() => { + expect(mocks.confirm).toHaveBeenCalledOnce(); + }); + + const confirmInput = mocks.confirm.mock.calls[0][0]; + expect(confirmInput).toMatchObject({ + paymentType: 'razorpay', + paymentProvider: 'RAZORPAY', + }); + const base64 = confirmInput.paymentToken + .replace(/-/g, '+') + .replace(/_/g, '/'); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '='); + expect(JSON.parse(atob(padded))).toEqual({ + v: 1, + paymentId: 'pay_razorpay_456', + orderId: 'order_razorpay_123', + signature: 'signature_789', + }); + }); + + it('does not confirm when Razorpay reports a failed payment', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Pay now' })); + + await waitFor(() => expect(open).toHaveBeenCalledOnce()); + act(() => { + paymentFailedHandler?.(); + }); + + expect(mocks.confirm).not.toHaveBeenCalled(); + expect(screen.getByText('Error processing payment')).toBeTruthy(); + }); + + it('closes Checkout when no callback arrives within two minutes', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Pay now' })); + + await waitFor(() => expect(open).toHaveBeenCalledOnce()); + const timeoutCall = setTimeoutSpy.mock.calls.find( + ([, delay]) => delay === RAZORPAY_CALLBACK_TIMEOUT_MS + ); + expect(timeoutCall).toBeDefined(); + + act(() => { + (timeoutCall?.[0] as () => void)(); + }); + + expect(close).toHaveBeenCalledOnce(); + expect(mocks.confirm).not.toHaveBeenCalled(); + expect(screen.getByText('Error processing payment')).toBeTruthy(); + }); +}); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx new file mode 100644 index 00000000..069bbd56 --- /dev/null +++ b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx @@ -0,0 +1,261 @@ +import { LoaderCircle } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { useAuthorizeCheckout } from '@/components/checkout/payment/utils/use-authorize-checkout'; +import { encodeRazorpayPaymentToken } from '@/components/checkout/payment/utils/razorpay-payment-token'; +import { + PaymentProvider, + useConfirmCheckout, +} 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 { useLoadRazorpay } from '@/components/checkout/payment/utils/use-load-razorpay'; +import { normalizePhoneForRazorpay } from '@/components/checkout/utils/checkout-transformers'; +import { Button } from '@/components/ui/button'; +import { useGoDaddyContext } from '@/godaddy-provider'; +import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; +import { PaymentMethodType } from '@/types'; + +export const RAZORPAY_CALLBACK_TIMEOUT_MS = 2 * 60 * 1000; + +type RazorpaySuccessResponse = { + razorpay_payment_id?: string; + razorpay_order_id?: string; + razorpay_signature?: string; +}; + +type RazorpayOptions = { + key: string; + amount: number; + currency: string; + name?: string; + description: string; + order_id: string; + prefill?: { + name?: string; + email?: string; + contact?: string; + }; + handler: (response: RazorpaySuccessResponse) => void; + modal: { + ondismiss: () => void; + }; +}; + +type RazorpayInstance = { + open: () => void; + close: () => void; + on: ( + event: 'payment.failed', + handler: (response: unknown) => void + ) => void; +}; + +type RazorpayConstructor = new (options: RazorpayOptions) => RazorpayInstance; + +function getRazorpayConstructor(): RazorpayConstructor | undefined { + return (window as Window & { Razorpay?: RazorpayConstructor }).Razorpay; +} + +export function RazorpayCheckoutButton() { + const { t } = useGoDaddyContext(); + const { + razorpayConfig, + session, + setCheckoutErrors, + isConfirmingCheckout, + } = useCheckoutContext(); + const form = useFormContext(); + const authorizeCheckout = useAuthorizeCheckout(); + const confirmCheckout = useConfirmCheckout(); + const flushCheckoutSync = useFlushCheckoutSync(); + const isPaymentDisabled = useIsPaymentDisabled(); + const { isRazorpayLoaded, isRazorpayLoadFailed } = useLoadRazorpay(); + const [isWidgetOpen, setIsWidgetOpen] = useState(false); + const [error, setError] = useState(''); + const callbackTimeoutRef = useRef | null>(null); + + const clearCallbackTimeout = useCallback(() => { + if (callbackTimeoutRef.current) { + clearTimeout(callbackTimeoutRef.current); + callbackTimeoutRef.current = null; + } + }, []); + + useEffect(() => clearCallbackTimeout, [clearCallbackTimeout]); + + const handlePaymentSuccess = useCallback( + async (response: RazorpaySuccessResponse) => { + clearCallbackTimeout(); + const paymentId = response.razorpay_payment_id; + const orderId = response.razorpay_order_id; + const signature = response.razorpay_signature; + if (!paymentId || !orderId || !signature) { + setError(t.errors.errorProcessingPayment); + setIsWidgetOpen(false); + return; + } + + try { + const paymentToken = encodeRazorpayPaymentToken({ + v: 1, + paymentId, + orderId, + signature, + }); + await confirmCheckout.mutateAsync({ + paymentToken, + paymentType: PaymentMethodType.RAZORPAY, + paymentProvider: PaymentProvider.RAZORPAY, + }); + setError(''); + } catch (err: unknown) { + if (err instanceof GraphQLErrorWithCodes) { + setCheckoutErrors(err.codes); + } else { + setError(t.errors.errorProcessingPayment); + } + } finally { + setIsWidgetOpen(false); + } + }, + [ + confirmCheckout, + clearCallbackTimeout, + setCheckoutErrors, + t.errors.errorProcessingPayment, + ] + ); + + const handlePaymentFailure = useCallback(() => { + setError(t.errors.errorProcessingPayment); + }, [t.errors.errorProcessingPayment]); + + const handleClick = async () => { + if (isWidgetOpen || authorizeCheckout.isPending || isConfirmingCheckout) { + return; + } + + const valid = await form.trigger(); + if (!valid) { + const firstError = Object.keys(form.formState.errors)[0]; + if (firstError) form.setFocus(firstError); + return; + } + + setCheckoutErrors(undefined); + setError(''); + + try { + const { latestOrder } = await flushCheckoutSync({ + includeCurrentFormDiff: true, + }); + const total = latestOrder?.totals?.total; + if ( + !latestOrder?.id || + total?.value == null || + !total.currencyCode + ) { + throw new Error('Synchronized draft order is unavailable'); + } + + const contact = latestOrder.billing ?? latestOrder.shipping; + const buyerName = [contact?.firstName, contact?.lastName] + .filter(Boolean) + .join(' '); + const buyerPhone = normalizePhoneForRazorpay( + contact?.phone, + contact?.address?.countryCode + ); + + const authorization = await authorizeCheckout.mutateAsync({ + paymentType: PaymentMethodType.RAZORPAY, + paymentProvider: PaymentProvider.RAZORPAY, + }); + const orderId = authorization?.transactionRefNum; + const Razorpay = getRazorpayConstructor(); + if ( + !orderId?.startsWith('order_') || + !razorpayConfig?.publicToken || + !isRazorpayLoaded || + !Razorpay + ) { + throw new Error('Razorpay Checkout configuration is unavailable'); + } + + const widget = new Razorpay({ + key: razorpayConfig.publicToken, + amount: total.value, + currency: total.currencyCode, + name: session?.storeName || undefined, + description: latestOrder.id, + order_id: orderId, + prefill: { + name: buyerName || undefined, + email: contact?.email || undefined, + contact: buyerPhone, + }, + handler: response => { + void handlePaymentSuccess(response); + }, + modal: { + ondismiss: () => { + clearCallbackTimeout(); + setIsWidgetOpen(false); + }, + }, + }); + widget.on('payment.failed', handlePaymentFailure); + + setIsWidgetOpen(true); + callbackTimeoutRef.current = setTimeout(() => { + callbackTimeoutRef.current = null; + widget.close(); + setIsWidgetOpen(false); + setError(t.errors.errorProcessingPayment); + }, RAZORPAY_CALLBACK_TIMEOUT_MS); + widget.open(); + } catch (err: unknown) { + clearCallbackTimeout(); + setIsWidgetOpen(false); + if (err instanceof GraphQLErrorWithCodes) { + setCheckoutErrors(err.codes); + } else { + setError(t.errors.errorProcessingPayment); + } + } + }; + + const isBusy = + isPaymentDisabled || + isWidgetOpen || + authorizeCheckout.isPending || + isConfirmingCheckout; + + return ( +
+ {error || isRazorpayLoadFailed ? ( +

+ {error || t.errors.failedToInitializePayment} +

+ ) : null} + +
+ ); +} diff --git a/packages/react/src/components/checkout/payment/icons/Razorpay.tsx b/packages/react/src/components/checkout/payment/icons/Razorpay.tsx new file mode 100644 index 00000000..8d03dfa3 --- /dev/null +++ b/packages/react/src/components/checkout/payment/icons/Razorpay.tsx @@ -0,0 +1,19 @@ +export const RazorpayIcon = ({ className }: { className?: string }) => { + return ( + + Razorpay + + + + ); +}; + +export default RazorpayIcon; diff --git a/packages/react/src/components/checkout/payment/lazy-payment-loader.tsx b/packages/react/src/components/checkout/payment/lazy-payment-loader.tsx index 7d0f6c32..88642972 100644 --- a/packages/react/src/components/checkout/payment/lazy-payment-loader.tsx +++ b/packages/react/src/components/checkout/payment/lazy-payment-loader.tsx @@ -152,6 +152,11 @@ const LazyComponents = { '@/components/checkout/payment/checkout-buttons/ccavenue/ccavenue' ).then(module => ({ default: module.CCAvenueCheckoutButton })) ), + RazorpayCheckoutButton: lazy(() => + import( + '@/components/checkout/payment/checkout-buttons/razorpay/razorpay' + ).then(module => ({ default: module.RazorpayCheckoutButton })) + ), // Container Components CreditCardContainer: lazy(() => @@ -238,6 +243,11 @@ type PaymentComponentRegistry = { button: PaymentComponentKey; }; }; + [PaymentMethodType.RAZORPAY]?: { + [PaymentProvider.RAZORPAY]: { + button: PaymentComponentKey; + }; + }; }; export const lazyPaymentComponentRegistry: PaymentComponentRegistry = { @@ -308,6 +318,11 @@ export const lazyPaymentComponentRegistry: PaymentComponentRegistry = { button: 'CCAvenueCheckoutButton', }, }, + [PaymentMethodType.RAZORPAY]: { + [PaymentProvider.RAZORPAY]: { + button: 'RazorpayCheckoutButton', + }, + }, }; // Payment loading skeleton component diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx index a38dc70d..bdfaae3b 100644 --- a/packages/react/src/components/checkout/payment/payment-form.tsx +++ b/packages/react/src/components/checkout/payment/payment-form.tsx @@ -28,6 +28,7 @@ import GooglePayIcon from '@/components/checkout/payment/icons/GooglePay'; import MercadoPagoIcon from '@/components/checkout/payment/icons/MercadoPago'; import PayPalIcon from '@/components/checkout/payment/icons/PayPal'; import PazeIcon from '@/components/checkout/payment/icons/Paze'; +import RazorpayIcon from '@/components/checkout/payment/icons/Razorpay'; import { hasPaymentMethodButton, hasPaymentMethodForm, @@ -88,6 +89,12 @@ const PAYMENT_METHOD_ICONS: Record = { mercadopago: , offline: , ccavenue: , + razorpay: , +}; + +type SessionPaymentMethodConfig = { + processor: AvailablePaymentProviders; + checkoutTypes: string[]; }; export function PaymentForm( @@ -101,6 +108,7 @@ export function PaymentForm( setCheckoutErrors, requiredFields, godaddyPaymentsConfig, + razorpayConfig, } = useCheckoutContext(); const form = useFormContext(); const paymentMethod = form.watch('paymentMethod'); @@ -131,6 +139,9 @@ export function PaymentForm( const countryCode = session?.shipping?.originAddress?.countryCode || 'US'; const applicationId = getApplicationId(session, godaddyPaymentsConfig?.appId); const businessId = godaddyPaymentsConfig?.businessId || session?.businessId; + const configuredPaymentMethods = session?.paymentMethods as unknown as + | Partial> + | undefined; // Helper function to get translated payment method labels const getPaymentMethodLabel = useCallback( @@ -154,6 +165,8 @@ export function PaymentForm( return t.payment.methods.mercadopago; case PaymentMethodType.CCAVENUE: return t.payment.methods.ccavenue; + case PaymentMethodType.RAZORPAY: + return t.payment.methods.razorpay; default: return key; } @@ -183,6 +196,8 @@ export function PaymentForm( return t.payment.descriptions?.mercadopago; case PaymentMethodType.CCAVENUE: return t.payment.descriptions?.ccavenue; + case PaymentMethodType.RAZORPAY: + return t.payment.descriptions?.razorpay; default: return undefined; } @@ -243,9 +258,9 @@ export function PaymentForm( const hasGoDaddyAppId = !!applicationId?.trim(); const availablePaymentMethods = React.useMemo(() => { - if (!session?.paymentMethods) return []; - return Object.keys(session.paymentMethods).filter(key => { - const method = session.paymentMethods?.[key as PaymentMethodValue]; + if (!configuredPaymentMethods) return []; + return Object.keys(configuredPaymentMethods).filter(key => { + const method = configuredPaymentMethods[key as PaymentMethodValue]; const baseCheck = PAYMENT_METHOD_ICONS[key as PaymentMethodValue] && @@ -292,14 +307,19 @@ export function PaymentForm( return baseCheck && googlePaySupported === true; } + if (key === PaymentMethodType.RAZORPAY) { + return baseCheck && !!razorpayConfig?.publicToken; + } + return baseCheck; }); }, [ - session, + configuredPaymentMethods, hasGoDaddyAppId, pazeSupported, applePaySupported, googlePaySupported, + razorpayConfig?.publicToken, ]); const shouldShowBilling = @@ -457,7 +477,9 @@ export function PaymentForm( {filteredPaymentMethods.map( ([key, { label, icon }], index, array) => { const itemMethodConfig = - session?.paymentMethods?.[key as PaymentMethodValue]; + configuredPaymentMethods?.[ + key as PaymentMethodValue + ]; const itemMethodForm = itemMethodConfig ? getPaymentMethodForm( key as PaymentMethodValue, diff --git a/packages/react/src/components/checkout/payment/utils/razorpay-payment-token.test.ts b/packages/react/src/components/checkout/payment/utils/razorpay-payment-token.test.ts new file mode 100644 index 00000000..1beff75d --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/razorpay-payment-token.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { encodeRazorpayPaymentToken } from './razorpay-payment-token'; + +describe('encodeRazorpayPaymentToken', () => { + it('encodes the versioned handler response as unpadded base64url', () => { + const token = encodeRazorpayPaymentToken({ + v: 1, + paymentId: 'pay_29QQoUBi66xm2f', + orderId: 'order_9A33XWu170gUtm', + signature: '9ef4dffbfd84f1318', + }); + + expect(token).toMatch(/^[A-Za-z0-9_-]+$/u); + + const base64 = token.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '='); + expect(JSON.parse(atob(padded))).toEqual({ + v: 1, + paymentId: 'pay_29QQoUBi66xm2f', + orderId: 'order_9A33XWu170gUtm', + signature: '9ef4dffbfd84f1318', + }); + }); +}); diff --git a/packages/react/src/components/checkout/payment/utils/razorpay-payment-token.ts b/packages/react/src/components/checkout/payment/utils/razorpay-payment-token.ts new file mode 100644 index 00000000..8512b647 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/razorpay-payment-token.ts @@ -0,0 +1,21 @@ +export type RazorpayPaymentTokenV1 = { + v: 1; + paymentId: string; + orderId: string; + signature: string; +}; + +export function encodeRazorpayPaymentToken( + payload: RazorpayPaymentTokenV1 +): string { + const bytes = new TextEncoder().encode(JSON.stringify(payload)); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/u, ''); +} diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts index 72e06442..c7830de1 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts @@ -87,6 +87,7 @@ export enum PaymentProvider { SQUARE = 'SQUARE', OFFLINE = 'OFFLINE', CCAVENUE = 'CCAVENUE', + RAZORPAY = 'RAZORPAY', } export function useConfirmCheckout() { diff --git a/packages/react/src/components/checkout/payment/utils/use-get-selected-payment-method.ts b/packages/react/src/components/checkout/payment/utils/use-get-selected-payment-method.ts index 5090de9d..3eab90d7 100644 --- a/packages/react/src/components/checkout/payment/utils/use-get-selected-payment-method.ts +++ b/packages/react/src/components/checkout/payment/utils/use-get-selected-payment-method.ts @@ -10,14 +10,16 @@ export function useGetSelectedPaymentMethod( return useMemo(() => { if (!paymentMethod || !session?.paymentMethods) return null; - const methodConfig = session.paymentMethods[ - paymentMethod as PaymentMethodValue - ] as PaymentMethodConfig; + const paymentMethods = session.paymentMethods as unknown as Partial< + Record + >; + const methodConfig = paymentMethods[paymentMethod]; + if (!methodConfig) return null; return { type: paymentMethod, - processor: methodConfig?.processor, - checkoutTypes: methodConfig?.checkoutTypes || [], + processor: methodConfig.processor, + checkoutTypes: methodConfig.checkoutTypes || [], }; }, [paymentMethod, session?.paymentMethods]); } diff --git a/packages/react/src/components/checkout/payment/utils/use-load-razorpay.test.tsx b/packages/react/src/components/checkout/payment/utils/use-load-razorpay.test.tsx new file mode 100644 index 00000000..b29668e0 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/use-load-razorpay.test.tsx @@ -0,0 +1,41 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useLoadRazorpay } from './use-load-razorpay'; + +vi.mock('@/components/checkout/checkout', () => ({ + useCheckoutContext: () => ({ + razorpayConfig: { publicToken: 'rzp_test_public' }, + }), +})); + +describe('useLoadRazorpay', () => { + afterEach(() => { + document.getElementById('razorpay-sdk')?.remove(); + Reflect.deleteProperty(window, 'Razorpay'); + }); + + it('loads the Razorpay Checkout SDK once', async () => { + const { result } = renderHook(() => useLoadRazorpay()); + const script = document.getElementById( + 'razorpay-sdk' + ) as HTMLScriptElement | null; + + expect(script?.src).toBe( + 'https://checkout.razorpay.com/v1/checkout.js' + ); + expect(result.current.isRazorpayLoaded).toBe(false); + + Object.defineProperty(window, 'Razorpay', { + configurable: true, + value: vi.fn(), + }); + act(() => { + script?.dispatchEvent(new Event('load')); + }); + + await waitFor(() => { + expect(result.current.isRazorpayLoaded).toBe(true); + expect(result.current.isRazorpayLoadFailed).toBe(false); + }); + }); +}); diff --git a/packages/react/src/components/checkout/payment/utils/use-load-razorpay.ts b/packages/react/src/components/checkout/payment/utils/use-load-razorpay.ts new file mode 100644 index 00000000..1a0f8e72 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/use-load-razorpay.ts @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react'; +import { useCheckoutContext } from '@/components/checkout/checkout'; + +const RAZORPAY_SDK_ID = 'razorpay-sdk'; +const RAZORPAY_SDK_URL = 'https://checkout.razorpay.com/v1/checkout.js'; + +let isRazorpayLoaded = false; +let isRazorpayScriptRequested = false; +const listeners = new Set<(loaded: boolean, failed: boolean) => void>(); + +function hasRazorpayConstructor() { + return typeof (window as Window & { Razorpay?: unknown }).Razorpay === 'function'; +} + +function notifyListeners(loaded: boolean, failed: boolean) { + listeners.forEach(listener => listener(loaded, failed)); +} + +export function useLoadRazorpay() { + const { razorpayConfig } = useCheckoutContext(); + const [loaded, setLoaded] = useState( + () => typeof window !== 'undefined' && hasRazorpayConstructor() + ); + const [failed, setFailed] = useState(false); + + useEffect(() => { + const updateState = (nextLoaded: boolean, nextFailed: boolean) => { + setLoaded(nextLoaded); + setFailed(nextFailed); + }; + listeners.add(updateState); + + if (isRazorpayLoaded || hasRazorpayConstructor()) { + isRazorpayLoaded = true; + updateState(true, false); + } + + return () => { + listeners.delete(updateState); + }; + }, []); + + useEffect(() => { + if (!razorpayConfig?.publicToken || isRazorpayLoaded) return; + + const existingScript = document.getElementById( + RAZORPAY_SDK_ID + ) as HTMLScriptElement | null; + + if (existingScript) { + isRazorpayScriptRequested = true; + const handleLoad = () => { + isRazorpayLoaded = hasRazorpayConstructor(); + notifyListeners(isRazorpayLoaded, !isRazorpayLoaded); + }; + const handleError = () => { + isRazorpayScriptRequested = false; + notifyListeners(false, true); + }; + existingScript.addEventListener('load', handleLoad); + existingScript.addEventListener('error', handleError); + return () => { + existingScript.removeEventListener('load', handleLoad); + existingScript.removeEventListener('error', handleError); + }; + } + + if (isRazorpayScriptRequested) return; + + isRazorpayScriptRequested = true; + const script = document.createElement('script'); + script.id = RAZORPAY_SDK_ID; + script.src = RAZORPAY_SDK_URL; + script.async = true; + script.onload = () => { + isRazorpayLoaded = hasRazorpayConstructor(); + notifyListeners(isRazorpayLoaded, !isRazorpayLoaded); + }; + script.onerror = () => { + isRazorpayScriptRequested = false; + notifyListeners(false, true); + }; + document.body.appendChild(script); + }, [razorpayConfig?.publicToken]); + + return { + isRazorpayLoaded: loaded, + isRazorpayLoadFailed: failed, + }; +} diff --git a/packages/react/src/components/checkout/utils/checkout-transformers.test.ts b/packages/react/src/components/checkout/utils/checkout-transformers.test.ts index 920ccf22..2e51b0a6 100644 --- a/packages/react/src/components/checkout/utils/checkout-transformers.test.ts +++ b/packages/react/src/components/checkout/utils/checkout-transformers.test.ts @@ -12,6 +12,7 @@ const DeliveryMethods = { import { mapOrderToFormValues, mapSkusToItemsDisplay, + normalizePhoneForRazorpay, } from './checkout-transformers'; type DeepPartial = T extends Array @@ -26,6 +27,26 @@ type DraftOrderLineItem = NonNullable[number]; type DraftOrderContact = NonNullable; type DraftOrderAddress = NonNullable; +describe('normalizePhoneForRazorpay', () => { + it('preserves a valid E.164 phone number', () => { + expect(normalizePhoneForRazorpay('+442079460958')).toBe('+442079460958'); + }); + + it('normalizes a national number only when its persisted country is known', () => { + expect(normalizePhoneForRazorpay('(201) 555-0123', 'US')).toBe( + '+12015550123' + ); + expect(normalizePhoneForRazorpay('020 7946 0958', 'GB')).toBe( + '+442079460958' + ); + expect(normalizePhoneForRazorpay('(201) 555-0123')).toBeUndefined(); + }); + + it('omits invalid phone input', () => { + expect(normalizePhoneForRazorpay('not-a-phone', 'US')).toBeUndefined(); + }); +}); + const money = (value: number, currencyCode = 'USD') => ({ value, currencyCode, diff --git a/packages/react/src/components/checkout/utils/checkout-transformers.ts b/packages/react/src/components/checkout/utils/checkout-transformers.ts index 7b51b3ca..634858a5 100644 --- a/packages/react/src/components/checkout/utils/checkout-transformers.ts +++ b/packages/react/src/components/checkout/utils/checkout-transformers.ts @@ -42,6 +42,34 @@ function processPhoneNumber( } } +/** + * Normalizes a persisted checkout contact phone for Razorpay prefill. + * Unlike the general checkout normalizer, this never assumes a default country. + */ +export function normalizePhoneForRazorpay( + phoneValue?: string | null, + countryCode?: string | null +): string | undefined { + const phone = phoneValue?.trim(); + if (!phone) return undefined; + + try { + const parsed = parsePhoneNumber(phone); + if (parsed?.isValid()) return parsed.number; + } catch { + // A national number requires an explicit persisted country below. + } + + if (!countryCode) return undefined; + + try { + const parsed = parsePhoneNumber(phone, countryCode as Country); + return parsed?.isValid() ? parsed.number : undefined; + } catch { + return undefined; + } +} + type DraftOrderAddress = NonNullable< NonNullable['address'] >; diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index c9ab79e9..85d1c494 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -3598,6 +3598,15 @@ const introspection = { }, "args": [], "isDeprecated": false + }, + { + "name": "razorpay", + "type": { + "kind": "OBJECT", + "name": "CheckoutSessionPaymentMethodConfig" + }, + "args": [], + "isDeprecated": false } ], "interfaces": [] @@ -3675,6 +3684,13 @@ const introspection = { "kind": "INPUT_OBJECT", "name": "CheckoutSessionPaymentMethodConfigInput" } + }, + { + "name": "razorpay", + "type": { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionPaymentMethodConfigInput" + } } ], "isOneOf": false diff --git a/packages/react/src/lib/godaddy/checkout-queries.ts b/packages/react/src/lib/godaddy/checkout-queries.ts index 4e3e6185..6124abb7 100644 --- a/packages/react/src/lib/godaddy/checkout-queries.ts +++ b/packages/react/src/lib/godaddy/checkout-queries.ts @@ -138,6 +138,10 @@ export const GetCheckoutSessionQuery = graphql(` processor checkoutTypes } + razorpay { + processor + checkoutTypes + } } locations { id diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index a2a06376..68eab44c 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -45,6 +45,7 @@ export const PaymentProvider = { OFFLINE: 'offline', MERCADOPAGO: 'mercadopago', CCAVENUE: 'ccavenue', + RAZORPAY: 'razorpay', } as const; export const CheckoutType = { @@ -67,6 +68,7 @@ export const PaymentMethodType = { PAZE: 'paze', MERCADOPAGO: 'mercadopago', CCAVENUE: 'ccavenue', + RAZORPAY: 'razorpay', } as const; // Union of all payment method keys From a5d973610a7af5b3fd3d22e71560aa6a28e76bb7 Mon Sep 17 00:00:00 2001 From: pchawla-godaddy Date: Mon, 14 Sep 2026 16:25:53 +0530 Subject: [PATCH 02/12] Align Razorpay checkout with authorizeCheckoutSession references. Read the widget key and order id from the authorize response instead of a client config prop, matching the merged checkout-api contract. Co-authored-by: Cursor --- examples/nextjs/app/checkout.tsx | 7 -- examples/nextjs/env.sample | 3 - .../__tests__/checkout-razorpay.test.tsx | 19 +++-- .../src/components/checkout/checkout.tsx | 8 -- .../razorpay/razorpay.test.tsx | 79 +++++++++++++------ .../checkout-buttons/razorpay/razorpay.tsx | 33 +++----- .../checkout/payment/payment-form.tsx | 10 +-- .../payment/utils/use-load-razorpay.test.tsx | 10 +-- .../payment/utils/use-load-razorpay.ts | 10 +-- .../react/src/lib/godaddy/checkout-env.ts | 73 +++++++++++++++++ .../src/lib/godaddy/checkout-mutations.ts | 16 ++++ 11 files changed, 172 insertions(+), 96 deletions(-) diff --git a/examples/nextjs/app/checkout.tsx b/examples/nextjs/app/checkout.tsx index 7166e208..25544408 100644 --- a/examples/nextjs/app/checkout.tsx +++ b/examples/nextjs/app/checkout.tsx @@ -67,13 +67,6 @@ export function CheckoutPage({ session }: { session: CheckoutSession }) { } : undefined } - razorpayConfig={ - process.env.NEXT_PUBLIC_RAZORPAY_PUBLIC_TOKEN - ? { - publicToken: process.env.NEXT_PUBLIC_RAZORPAY_PUBLIC_TOKEN, - } - : undefined - } /> ); } diff --git a/examples/nextjs/env.sample b/examples/nextjs/env.sample index 60c8b592..abd13fa6 100644 --- a/examples/nextjs/env.sample +++ b/examples/nextjs/env.sample @@ -25,6 +25,3 @@ NEXT_PUBLIC_PAYPAL_CLIENT_ID= # MercadoPago Credentials NEXT_PUBLIC_MERCADOPAGO_PUBLIC_KEY= NEXT_PUBLIC_MERCADOPAGO_COUNTRY=AR - -# Razorpay OAuth Public Token (safe for client-side Checkout initialization) -NEXT_PUBLIC_RAZORPAY_PUBLIC_TOKEN= diff --git a/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx index b8837c0c..8f4d28c4 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx @@ -19,23 +19,22 @@ function buildRazorpaySession() { } describe('Razorpay payment method', () => { - it('renders the Razorpay checkout button when session and public token are configured', async () => { - renderCheckout({ - session: buildRazorpaySession(), - checkoutProps: { - razorpayConfig: { publicToken: 'rzp_test_public' }, - }, - }); + it('renders the Razorpay checkout button when enabled for the session', async () => { + renderCheckout({ session: buildRazorpaySession() }); await waitForCheckoutReady(); expect(await screen.findByTestId('mock-razorpay-button')).toBeVisible(); }); - it('hides Razorpay when the public token is unavailable', async () => { - renderCheckout({ session: buildRazorpaySession() }); + it('hides Razorpay when it is not enabled for the session', async () => { + const session = buildCheckoutSession(); + session.paymentMethods = {} as never; + renderCheckout({ session }); await waitForCheckoutReady(); expect(screen.getByText('No payment methods available')).toBeVisible(); - expect(screen.queryByTestId('mock-razorpay-button')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('mock-razorpay-button') + ).not.toBeInTheDocument(); }); }); diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index aac029e0..bf532b01 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -94,10 +94,6 @@ export type CCAvenueConfig = { accessCodeId: string; }; -export type RazorpayConfig = { - publicToken: string; -}; - interface CheckoutContextValue { elements?: CheckoutElements; targets?: Partial< @@ -112,7 +108,6 @@ interface CheckoutContextValue { paypalConfig?: PayPalConfig; mercadoPagoConfig?: MercadoPagoConfig; ccavenueConfig?: CCAvenueConfig; - razorpayConfig?: RazorpayConfig; isConfirmingCheckout: boolean; setIsConfirmingCheckout: (isConfirming: boolean) => void; checkoutErrors?: string[] | undefined; @@ -219,7 +214,6 @@ export interface CheckoutProps { paypalConfig?: PayPalConfig; mercadoPagoConfig?: MercadoPagoConfig; ccavenueConfig?: CCAvenueConfig; - razorpayConfig?: RazorpayConfig; layout?: LayoutSection[]; direction?: 'ltr' | 'rtl'; showStoreHours?: boolean; @@ -245,7 +239,6 @@ export function Checkout(props: CheckoutProps) { paypalConfig, mercadoPagoConfig, ccavenueConfig, - razorpayConfig, isCheckoutDisabled, } = props; @@ -340,7 +333,6 @@ export function Checkout(props: CheckoutProps) { mercadoPagoConfig, paypalConfig, ccavenueConfig, - razorpayConfig, requiredFields, isConfirmingCheckout, setIsConfirmingCheckout, diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx index 9e8f0790..d62547d3 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx @@ -1,4 +1,10 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { RAZORPAY_CALLBACK_TIMEOUT_MS, @@ -17,41 +23,31 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/components/checkout/checkout', () => ({ useCheckoutContext: () => ({ - razorpayConfig: { publicToken: 'rzp_test_public' }, session: { storeName: 'Test Store' }, setCheckoutErrors: mocks.setCheckoutErrors, isConfirmingCheckout: false, }), })); -vi.mock( - '@/components/checkout/payment/utils/use-authorize-checkout', - () => ({ - useAuthorizeCheckout: () => ({ - mutateAsync: mocks.authorize, - isPending: false, - }), - }) -); +vi.mock('@/components/checkout/payment/utils/use-authorize-checkout', () => ({ + useAuthorizeCheckout: () => ({ + mutateAsync: mocks.authorize, + isPending: false, + }), +})); vi.mock('@/components/checkout/payment/utils/use-confirm-checkout', () => ({ PaymentProvider: { RAZORPAY: 'RAZORPAY' }, useConfirmCheckout: () => ({ mutateAsync: mocks.confirm }), })); -vi.mock( - '@/components/checkout/payment/utils/use-flush-checkout-sync', - () => ({ - useFlushCheckoutSync: () => mocks.flush, - }) -); +vi.mock('@/components/checkout/payment/utils/use-flush-checkout-sync', () => ({ + useFlushCheckoutSync: () => mocks.flush, +})); -vi.mock( - '@/components/checkout/payment/utils/use-is-payment-disabled', - () => ({ - useIsPaymentDisabled: () => false, - }) -); +vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({ + useIsPaymentDisabled: () => false, +})); vi.mock('@/components/checkout/payment/utils/use-load-razorpay', () => ({ useLoadRazorpay: () => ({ @@ -112,7 +108,11 @@ describe('RazorpayCheckoutButton', () => { }, }); mocks.authorize.mockResolvedValue({ - transactionRefNum: 'order_razorpay_123', + fundingSource: { paymentReference: 'order_razorpay_123' }, + references: [ + { type: 'ORDER', value: 'draft-order-1' }, + { type: 'MERCHANT_PUBLIC_KEY', value: 'rzp_test_public' }, + ], }); mocks.confirm.mockResolvedValue(undefined); @@ -205,6 +205,37 @@ describe('RazorpayCheckoutButton', () => { expect(screen.getByText('Error processing payment')).toBeTruthy(); }); + it.each([ + { + name: 'order ID', + authorization: { + fundingSource: { paymentReference: null }, + references: [{ type: 'MERCHANT_PUBLIC_KEY', value: 'rzp_test_public' }], + }, + }, + { + name: 'merchant public key', + authorization: { + fundingSource: { paymentReference: 'order_razorpay_123' }, + references: [], + }, + }, + ])( + 'does not open Checkout when authorize omits the $name', + async ({ authorization }) => { + mocks.authorize.mockResolvedValue(authorization); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Pay now' })); + + await waitFor(() => { + expect(screen.getByText('Error processing payment')).toBeTruthy(); + }); + expect(open).not.toHaveBeenCalled(); + expect(mocks.confirm).not.toHaveBeenCalled(); + } + ); + it('closes Checkout when no callback arrives within two minutes', async () => { const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); render(); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx index 069bbd56..6017a322 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx @@ -2,8 +2,8 @@ import { LoaderCircle } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { useAuthorizeCheckout } from '@/components/checkout/payment/utils/use-authorize-checkout'; import { encodeRazorpayPaymentToken } from '@/components/checkout/payment/utils/razorpay-payment-token'; +import { useAuthorizeCheckout } from '@/components/checkout/payment/utils/use-authorize-checkout'; import { PaymentProvider, useConfirmCheckout, @@ -46,10 +46,7 @@ type RazorpayOptions = { type RazorpayInstance = { open: () => void; close: () => void; - on: ( - event: 'payment.failed', - handler: (response: unknown) => void - ) => void; + on: (event: 'payment.failed', handler: (response: unknown) => void) => void; }; type RazorpayConstructor = new (options: RazorpayOptions) => RazorpayInstance; @@ -60,12 +57,8 @@ function getRazorpayConstructor(): RazorpayConstructor | undefined { export function RazorpayCheckoutButton() { const { t } = useGoDaddyContext(); - const { - razorpayConfig, - session, - setCheckoutErrors, - isConfirmingCheckout, - } = useCheckoutContext(); + const { session, setCheckoutErrors, isConfirmingCheckout } = + useCheckoutContext(); const form = useFormContext(); const authorizeCheckout = useAuthorizeCheckout(); const confirmCheckout = useConfirmCheckout(); @@ -109,7 +102,6 @@ export function RazorpayCheckoutButton() { paymentType: PaymentMethodType.RAZORPAY, paymentProvider: PaymentProvider.RAZORPAY, }); - setError(''); } catch (err: unknown) { if (err instanceof GraphQLErrorWithCodes) { setCheckoutErrors(err.codes); @@ -152,11 +144,7 @@ export function RazorpayCheckoutButton() { includeCurrentFormDiff: true, }); const total = latestOrder?.totals?.total; - if ( - !latestOrder?.id || - total?.value == null || - !total.currencyCode - ) { + if (!latestOrder?.id || total?.value == null || !total.currencyCode) { throw new Error('Synchronized draft order is unavailable'); } @@ -173,11 +161,14 @@ export function RazorpayCheckoutButton() { paymentType: PaymentMethodType.RAZORPAY, paymentProvider: PaymentProvider.RAZORPAY, }); - const orderId = authorization?.transactionRefNum; + const orderId = authorization?.fundingSource?.paymentReference; + const publicToken = authorization?.references?.find( + reference => reference.type === 'MERCHANT_PUBLIC_KEY' + )?.value; const Razorpay = getRazorpayConstructor(); if ( !orderId?.startsWith('order_') || - !razorpayConfig?.publicToken || + !publicToken || !isRazorpayLoaded || !Razorpay ) { @@ -185,7 +176,7 @@ export function RazorpayCheckoutButton() { } const widget = new Razorpay({ - key: razorpayConfig.publicToken, + key: publicToken, amount: total.value, currency: total.currencyCode, name: session?.storeName || undefined, @@ -244,7 +235,7 @@ export function RazorpayCheckoutButton() { type='button' size='lg' className='w-full' - disabled={isBusy || !isRazorpayLoaded || !razorpayConfig?.publicToken} + disabled={isBusy || !isRazorpayLoaded} onClick={handleClick} > {isBusy ? ( diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx index bdfaae3b..f67311de 100644 --- a/packages/react/src/components/checkout/payment/payment-form.tsx +++ b/packages/react/src/components/checkout/payment/payment-form.tsx @@ -108,7 +108,6 @@ export function PaymentForm( setCheckoutErrors, requiredFields, godaddyPaymentsConfig, - razorpayConfig, } = useCheckoutContext(); const form = useFormContext(); const paymentMethod = form.watch('paymentMethod'); @@ -307,10 +306,6 @@ export function PaymentForm( return baseCheck && googlePaySupported === true; } - if (key === PaymentMethodType.RAZORPAY) { - return baseCheck && !!razorpayConfig?.publicToken; - } - return baseCheck; }); }, [ @@ -319,7 +314,6 @@ export function PaymentForm( pazeSupported, applePaySupported, googlePaySupported, - razorpayConfig?.publicToken, ]); const shouldShowBilling = @@ -477,9 +471,7 @@ export function PaymentForm( {filteredPaymentMethods.map( ([key, { label, icon }], index, array) => { const itemMethodConfig = - configuredPaymentMethods?.[ - key as PaymentMethodValue - ]; + configuredPaymentMethods?.[key as PaymentMethodValue]; const itemMethodForm = itemMethodConfig ? getPaymentMethodForm( key as PaymentMethodValue, diff --git a/packages/react/src/components/checkout/payment/utils/use-load-razorpay.test.tsx b/packages/react/src/components/checkout/payment/utils/use-load-razorpay.test.tsx index b29668e0..681beef0 100644 --- a/packages/react/src/components/checkout/payment/utils/use-load-razorpay.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-load-razorpay.test.tsx @@ -2,12 +2,6 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { useLoadRazorpay } from './use-load-razorpay'; -vi.mock('@/components/checkout/checkout', () => ({ - useCheckoutContext: () => ({ - razorpayConfig: { publicToken: 'rzp_test_public' }, - }), -})); - describe('useLoadRazorpay', () => { afterEach(() => { document.getElementById('razorpay-sdk')?.remove(); @@ -20,9 +14,7 @@ describe('useLoadRazorpay', () => { 'razorpay-sdk' ) as HTMLScriptElement | null; - expect(script?.src).toBe( - 'https://checkout.razorpay.com/v1/checkout.js' - ); + expect(script?.src).toBe('https://checkout.razorpay.com/v1/checkout.js'); expect(result.current.isRazorpayLoaded).toBe(false); Object.defineProperty(window, 'Razorpay', { diff --git a/packages/react/src/components/checkout/payment/utils/use-load-razorpay.ts b/packages/react/src/components/checkout/payment/utils/use-load-razorpay.ts index 1a0f8e72..e339232e 100644 --- a/packages/react/src/components/checkout/payment/utils/use-load-razorpay.ts +++ b/packages/react/src/components/checkout/payment/utils/use-load-razorpay.ts @@ -1,5 +1,4 @@ import { useEffect, useState } from 'react'; -import { useCheckoutContext } from '@/components/checkout/checkout'; const RAZORPAY_SDK_ID = 'razorpay-sdk'; const RAZORPAY_SDK_URL = 'https://checkout.razorpay.com/v1/checkout.js'; @@ -9,7 +8,9 @@ let isRazorpayScriptRequested = false; const listeners = new Set<(loaded: boolean, failed: boolean) => void>(); function hasRazorpayConstructor() { - return typeof (window as Window & { Razorpay?: unknown }).Razorpay === 'function'; + return ( + typeof (window as Window & { Razorpay?: unknown }).Razorpay === 'function' + ); } function notifyListeners(loaded: boolean, failed: boolean) { @@ -17,7 +18,6 @@ function notifyListeners(loaded: boolean, failed: boolean) { } export function useLoadRazorpay() { - const { razorpayConfig } = useCheckoutContext(); const [loaded, setLoaded] = useState( () => typeof window !== 'undefined' && hasRazorpayConstructor() ); @@ -41,7 +41,7 @@ export function useLoadRazorpay() { }, []); useEffect(() => { - if (!razorpayConfig?.publicToken || isRazorpayLoaded) return; + if (isRazorpayLoaded) return; const existingScript = document.getElementById( RAZORPAY_SDK_ID @@ -81,7 +81,7 @@ export function useLoadRazorpay() { notifyListeners(false, true); }; document.body.appendChild(script); - }, [razorpayConfig?.publicToken]); + }, []); return { isRazorpayLoaded: loaded, diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index 85d1c494..ae7b7878 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -571,6 +571,21 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "references", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "TransactionReference" + } + } + }, + "args": [], + "isDeprecated": false + }, { "name": "status", "type": { @@ -9838,6 +9853,21 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "references", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "TransactionReference" + } + } + }, + "args": [], + "isDeprecated": false + }, { "name": "status", "type": { @@ -10873,6 +10903,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "paymentReference", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, { "name": "provider", "type": { @@ -10894,6 +10933,40 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "OBJECT", + "name": "TransactionReference", + "fields": [ + { + "name": "additionalLabel", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "type", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "value", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "INPUT_OBJECT", "name": "UpdateDiscountInput", diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index 39f4780a..fd594cb6 100644 --- a/packages/react/src/lib/godaddy/checkout-mutations.ts +++ b/packages/react/src/lib/godaddy/checkout-mutations.ts @@ -473,9 +473,25 @@ export const AuthorizeCheckoutSessionMutation = graphql(` authorizeCheckoutSession(input: $input) { ... on SaleTransaction { transactionRefNum + fundingSource { + paymentReference + } + references { + type + value + additionalLabel + } } ... on AuthorizeTransaction { transactionRefNum + fundingSource { + paymentReference + } + references { + type + value + additionalLabel + } } } } From 6ccf30e7bce10a0562d0aa2d6d651a62d8d8061e Mon Sep 17 00:00:00 2001 From: Pulkit Chawla Date: Tue, 15 Sep 2026 12:34:00 +0530 Subject: [PATCH 03/12] Update .changeset/bright-cats-pay.md Co-authored-by: Phil Bennett <114938978+pbennett1-godaddy@users.noreply.github.com> --- .changeset/bright-cats-pay.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/bright-cats-pay.md b/.changeset/bright-cats-pay.md index d8b2f932..9529d72e 100644 --- a/.changeset/bright-cats-pay.md +++ b/.changeset/bright-cats-pay.md @@ -1,5 +1,5 @@ --- -'@godaddy/react': minor +'@godaddy/react': patch '@godaddy/localizations': patch --- From bf415b9b1ca953fed4e581421fc11af2974978cf Mon Sep 17 00:00:00 2001 From: pchawla-godaddy Date: Wed, 16 Sep 2026 15:17:23 +0530 Subject: [PATCH 04/12] Replace the Razorpay SDK loader with a retrying provider and close remaining review gaps. Load state now lives in RazorpayLoaderProvider instead of module globals, failed script loads retry with backoff, and payment-method null handling plus create-session Razorpay fields match the checkout-api contract. Co-authored-by: Cursor --- .../__tests__/checkout-razorpay.test.tsx | 15 ++ .../razorpay/razorpay.test.tsx | 37 +++- .../checkout-buttons/razorpay/razorpay.tsx | 15 +- .../checkout/payment/icons/Razorpay.tsx | 5 +- .../checkout/payment/payment-form.tsx | 5 +- .../utils/razorpay-loader-context.test.tsx | 203 ++++++++++++++++++ .../payment/utils/razorpay-loader-context.tsx | 125 +++++++++++ .../utils/use-get-selected-payment-method.ts | 4 +- .../payment/utils/use-load-razorpay.test.tsx | 33 --- .../payment/utils/use-load-razorpay.ts | 90 -------- .../src/lib/godaddy/checkout-mutations.ts | 4 + 11 files changed, 403 insertions(+), 133 deletions(-) create mode 100644 packages/react/src/components/checkout/payment/utils/razorpay-loader-context.test.tsx create mode 100644 packages/react/src/components/checkout/payment/utils/razorpay-loader-context.tsx delete mode 100644 packages/react/src/components/checkout/payment/utils/use-load-razorpay.test.tsx delete mode 100644 packages/react/src/components/checkout/payment/utils/use-load-razorpay.ts diff --git a/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx index 8f4d28c4..6a7d9b33 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx @@ -37,4 +37,19 @@ describe('Razorpay payment method', () => { screen.queryByTestId('mock-razorpay-button') ).not.toBeInTheDocument(); }); + + it.each([ + ['paymentMethods is null', null], + ['the Razorpay config is null', { razorpay: null }], + ])('renders without crashing when %s', async (_label, paymentMethods) => { + const session = buildCheckoutSession(); + session.paymentMethods = paymentMethods as never; + renderCheckout({ session }); + await waitForCheckoutReady(); + + expect(screen.getByText('No payment methods available')).toBeVisible(); + expect( + screen.queryByTestId('mock-razorpay-button') + ).not.toBeInTheDocument(); + }); }); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx index d62547d3..8b5f72cd 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx @@ -5,6 +5,7 @@ import { screen, waitFor, } from '@testing-library/react'; +import type { ReactNode } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { RAZORPAY_CALLBACK_TIMEOUT_MS, @@ -49,8 +50,9 @@ vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({ useIsPaymentDisabled: () => false, })); -vi.mock('@/components/checkout/payment/utils/use-load-razorpay', () => ({ - useLoadRazorpay: () => ({ +vi.mock('@/components/checkout/payment/utils/razorpay-loader-context', () => ({ + RazorpayLoaderProvider: ({ children }: { children: ReactNode }) => children, + useRazorpayLoader: () => ({ isRazorpayLoaded: true, isRazorpayLoadFailed: false, }), @@ -192,6 +194,37 @@ describe('RazorpayCheckoutButton', () => { }); }); + it('waits for the checkout sync to settle before authorizing', async () => { + let resolveFlush: ((value: unknown) => void) | undefined; + mocks.flush.mockImplementation( + () => + new Promise(resolve => { + resolveFlush = resolve; + }) + ); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Pay now' })); + + await waitFor(() => expect(mocks.flush).toHaveBeenCalledOnce()); + // Authorize must see the synced totals, so it cannot start while the + // buyer's pending form edits are still in flight. + expect(mocks.authorize).not.toHaveBeenCalled(); + + await act(async () => { + resolveFlush?.({ + latestOrder: { + id: 'draft-order-1', + totals: { total: { value: 4200, currencyCode: 'INR' } }, + }, + }); + }); + + await waitFor(() => expect(open).toHaveBeenCalledOnce()); + expect(mocks.authorize).toHaveBeenCalledOnce(); + expect(capturedOptions?.amount).toBe(4200); + }); + it('does not confirm when Razorpay reports a failed payment', async () => { render(); fireEvent.click(screen.getByRole('button', { name: 'Pay now' })); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx index 6017a322..164d9c04 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx @@ -2,6 +2,10 @@ import { LoaderCircle } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { + RazorpayLoaderProvider, + useRazorpayLoader, +} from '@/components/checkout/payment/utils/razorpay-loader-context'; import { encodeRazorpayPaymentToken } from '@/components/checkout/payment/utils/razorpay-payment-token'; import { useAuthorizeCheckout } from '@/components/checkout/payment/utils/use-authorize-checkout'; import { @@ -10,7 +14,6 @@ import { } 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 { useLoadRazorpay } from '@/components/checkout/payment/utils/use-load-razorpay'; import { normalizePhoneForRazorpay } from '@/components/checkout/utils/checkout-transformers'; import { Button } from '@/components/ui/button'; import { useGoDaddyContext } from '@/godaddy-provider'; @@ -56,6 +59,14 @@ function getRazorpayConstructor(): RazorpayConstructor | undefined { } export function RazorpayCheckoutButton() { + return ( + + + + ); +} + +function RazorpayCheckoutButtonInner() { const { t } = useGoDaddyContext(); const { session, setCheckoutErrors, isConfirmingCheckout } = useCheckoutContext(); @@ -64,7 +75,7 @@ export function RazorpayCheckoutButton() { const confirmCheckout = useConfirmCheckout(); const flushCheckoutSync = useFlushCheckoutSync(); const isPaymentDisabled = useIsPaymentDisabled(); - const { isRazorpayLoaded, isRazorpayLoadFailed } = useLoadRazorpay(); + const { isRazorpayLoaded, isRazorpayLoadFailed } = useRazorpayLoader(); const [isWidgetOpen, setIsWidgetOpen] = useState(false); const [error, setError] = useState(''); const callbackTimeoutRef = useRef | null>(null); diff --git a/packages/react/src/components/checkout/payment/icons/Razorpay.tsx b/packages/react/src/components/checkout/payment/icons/Razorpay.tsx index 8d03dfa3..50b2c61d 100644 --- a/packages/react/src/components/checkout/payment/icons/Razorpay.tsx +++ b/packages/react/src/components/checkout/payment/icons/Razorpay.tsx @@ -7,10 +7,7 @@ export const RazorpayIcon = ({ className }: { className?: string }) => { fill='none' > Razorpay - + ); diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx index 18d6523d..0d047f64 100644 --- a/packages/react/src/components/checkout/payment/payment-form.tsx +++ b/packages/react/src/components/checkout/payment/payment-form.tsx @@ -138,8 +138,11 @@ export function PaymentForm( const countryCode = session?.shipping?.originAddress?.countryCode || 'US'; const applicationId = getApplicationId(session, godaddyPaymentsConfig?.appId); const businessId = godaddyPaymentsConfig?.businessId || session?.businessId; + // Both the container and each individual method are nullable on the session, + // so the cast has to admit null on both levels. const configuredPaymentMethods = session?.paymentMethods as unknown as - | Partial> + | Partial> + | null | undefined; // Helper function to get translated payment method labels diff --git a/packages/react/src/components/checkout/payment/utils/razorpay-loader-context.test.tsx b/packages/react/src/components/checkout/payment/utils/razorpay-loader-context.test.tsx new file mode 100644 index 00000000..7043b05e --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/razorpay-loader-context.test.tsx @@ -0,0 +1,203 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + RazorpayLoaderProvider, + useRazorpayLoader, +} from './razorpay-loader-context'; + +function Probe({ label = 'probe' }: { label?: string }) { + const { isRazorpayLoaded, isRazorpayLoadFailed } = useRazorpayLoader(); + return ( +
+ {`loaded:${isRazorpayLoaded} failed:${isRazorpayLoadFailed}`} +
+ ); +} + +function getScript() { + return document.getElementById('razorpay-sdk') as HTMLScriptElement | null; +} + +function markLoaded() { + Object.defineProperty(window, 'Razorpay', { + configurable: true, + value: vi.fn(), + }); +} + +describe('RazorpayLoaderProvider', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + document.getElementById('razorpay-sdk')?.remove(); + Reflect.deleteProperty(window, 'Razorpay'); + vi.useRealTimers(); + }); + + it('injects the SDK script and reports load success', () => { + render( + + + + ); + const script = getScript(); + + expect(script?.src).toBe('https://checkout.razorpay.com/v1/checkout.js'); + expect(screen.getByTestId('probe').textContent).toBe( + 'loaded:false failed:false' + ); + + markLoaded(); + act(() => { + script?.dispatchEvent(new Event('load')); + }); + + expect(screen.getByTestId('probe').textContent).toBe( + 'loaded:true failed:false' + ); + }); + + it('treats a load without the Razorpay constructor as a failure', () => { + render( + + + + ); + + act(() => { + getScript()?.dispatchEvent(new Event('load')); + }); + + expect(screen.getByTestId('probe').textContent).toBe( + 'loaded:false failed:true' + ); + }); + + it('retries loading the SDK on script error before giving up', () => { + render( + + + + ); + + act(() => { + getScript()?.dispatchEvent(new Event('error')); + }); + + // Script tag is replaced and the failure isn't surfaced yet - a retry is pending. + expect(getScript()).toBeNull(); + expect(screen.getByTestId('probe').textContent).toBe( + 'loaded:false failed:false' + ); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(getScript()).not.toBeNull(); + + markLoaded(); + act(() => { + getScript()?.dispatchEvent(new Event('load')); + }); + + expect(screen.getByTestId('probe').textContent).toBe( + 'loaded:true failed:false' + ); + }); + + it('surfaces a failure after exhausting all retries', () => { + render( + + + + ); + + for (let attempt = 0; attempt < 3; attempt += 1) { + act(() => { + getScript()?.dispatchEvent(new Event('error')); + }); + act(() => { + vi.advanceTimersByTime((attempt + 1) * 1000); + }); + } + + act(() => { + getScript()?.dispatchEvent(new Event('error')); + }); + + expect(screen.getByTestId('probe').textContent).toBe( + 'loaded:false failed:true' + ); + }); + + it('shares load state across consumers under the same provider', () => { + render( + + + + + ); + + // Only one script tag should be requested for both consumers. + expect(document.querySelectorAll('#razorpay-sdk')).toHaveLength(1); + + markLoaded(); + act(() => { + getScript()?.dispatchEvent(new Event('load')); + }); + + expect(screen.getByTestId('first').textContent).toBe( + 'loaded:true failed:false' + ); + expect(screen.getByTestId('second').textContent).toBe( + 'loaded:true failed:false' + ); + }); + + it('reuses an in-flight script tag on remount instead of duplicating it', () => { + const { unmount } = render( + + + + ); + expect(document.querySelectorAll('#razorpay-sdk')).toHaveLength(1); + + unmount(); + render( + + + + ); + + expect(document.querySelectorAll('#razorpay-sdk')).toHaveLength(1); + + markLoaded(); + act(() => { + getScript()?.dispatchEvent(new Event('load')); + }); + + expect(screen.getByTestId('probe').textContent).toBe( + 'loaded:true failed:false' + ); + }); + + it('stops listening to a reused script tag after unmount', () => { + const { unmount } = render( + + + + ); + const script = getScript(); + unmount(); + + // An unmounted provider must not retry, so its listener is detached and + // the shared script tag survives the error. + act(() => { + script?.dispatchEvent(new Event('error')); + }); + + expect(getScript()).not.toBeNull(); + }); +}); diff --git a/packages/react/src/components/checkout/payment/utils/razorpay-loader-context.tsx b/packages/react/src/components/checkout/payment/utils/razorpay-loader-context.tsx new file mode 100644 index 00000000..bfa86573 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/razorpay-loader-context.tsx @@ -0,0 +1,125 @@ +import { + createContext, + type ReactNode, + useContext, + useEffect, + useRef, + useState, +} from 'react'; + +const RAZORPAY_SDK_ID = 'razorpay-sdk'; +const RAZORPAY_SDK_URL = 'https://checkout.razorpay.com/v1/checkout.js'; +const MAX_LOAD_RETRIES = 3; +const RETRY_DELAY_MS = 1000; + +type RazorpayLoaderState = { + isRazorpayLoaded: boolean; + isRazorpayLoadFailed: boolean; +}; + +const RazorpayLoaderContext = createContext({ + isRazorpayLoaded: false, + isRazorpayLoadFailed: false, +}); + +function hasRazorpayConstructor() { + return ( + typeof (window as Window & { Razorpay?: unknown }).Razorpay === 'function' + ); +} + +export function RazorpayLoaderProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState(() => ({ + isRazorpayLoaded: typeof window !== 'undefined' && hasRazorpayConstructor(), + isRazorpayLoadFailed: false, + })); + const retryCountRef = useRef(0); + + useEffect(() => { + if (hasRazorpayConstructor()) { + setState({ isRazorpayLoaded: true, isRazorpayLoadFailed: false }); + return; + } + + let cancelled = false; + let retryTimeout: ReturnType | undefined; + let detachListeners: (() => void) | undefined; + let pendingScript: HTMLScriptElement | undefined; + + const handleLoad = () => { + if (cancelled) return; + retryCountRef.current = 0; + // A load event without the constructor means the SDK did not install + // itself, which is a failure rather than a usable Checkout. + const loaded = hasRazorpayConstructor(); + setState({ isRazorpayLoaded: loaded, isRazorpayLoadFailed: !loaded }); + }; + + const handleError = () => { + detachListeners?.(); + pendingScript?.remove(); + pendingScript = undefined; + if (cancelled) return; + + if (retryCountRef.current < MAX_LOAD_RETRIES) { + retryCountRef.current += 1; + retryTimeout = setTimeout( + loadScript, + RETRY_DELAY_MS * retryCountRef.current + ); + } else { + setState({ isRazorpayLoaded: false, isRazorpayLoadFailed: true }); + } + }; + + // A reused script tag outlives this provider, so the listeners have to be + // detachable on unmount to keep remounts from stacking callbacks. + function attachListeners(script: HTMLScriptElement) { + pendingScript = script; + script.addEventListener('load', handleLoad); + script.addEventListener('error', handleError); + detachListeners = () => { + script.removeEventListener('load', handleLoad); + script.removeEventListener('error', handleError); + detachListeners = undefined; + }; + } + + function loadScript() { + // The DOM is the source of truth for whether a script tag is already + // in flight, so no module-level flag is needed to dedupe requests. + const existingScript = document.getElementById( + RAZORPAY_SDK_ID + ) as HTMLScriptElement | null; + if (existingScript) { + attachListeners(existingScript); + return; + } + + const script = document.createElement('script'); + script.id = RAZORPAY_SDK_ID; + script.src = RAZORPAY_SDK_URL; + script.async = true; + attachListeners(script); + document.body.appendChild(script); + } + + loadScript(); + + return () => { + cancelled = true; + if (retryTimeout) clearTimeout(retryTimeout); + detachListeners?.(); + }; + }, []); + + return ( + + {children} + + ); +} + +export function useRazorpayLoader() { + return useContext(RazorpayLoaderContext); +} diff --git a/packages/react/src/components/checkout/payment/utils/use-get-selected-payment-method.ts b/packages/react/src/components/checkout/payment/utils/use-get-selected-payment-method.ts index 3eab90d7..386381f0 100644 --- a/packages/react/src/components/checkout/payment/utils/use-get-selected-payment-method.ts +++ b/packages/react/src/components/checkout/payment/utils/use-get-selected-payment-method.ts @@ -10,8 +10,10 @@ export function useGetSelectedPaymentMethod( return useMemo(() => { if (!paymentMethod || !session?.paymentMethods) return null; + // Every method on the session is individually nullable, so the cast has to + // admit null rather than promising a config for each configured key. const paymentMethods = session.paymentMethods as unknown as Partial< - Record + Record >; const methodConfig = paymentMethods[paymentMethod]; if (!methodConfig) return null; diff --git a/packages/react/src/components/checkout/payment/utils/use-load-razorpay.test.tsx b/packages/react/src/components/checkout/payment/utils/use-load-razorpay.test.tsx deleted file mode 100644 index 681beef0..00000000 --- a/packages/react/src/components/checkout/payment/utils/use-load-razorpay.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { useLoadRazorpay } from './use-load-razorpay'; - -describe('useLoadRazorpay', () => { - afterEach(() => { - document.getElementById('razorpay-sdk')?.remove(); - Reflect.deleteProperty(window, 'Razorpay'); - }); - - it('loads the Razorpay Checkout SDK once', async () => { - const { result } = renderHook(() => useLoadRazorpay()); - const script = document.getElementById( - 'razorpay-sdk' - ) as HTMLScriptElement | null; - - expect(script?.src).toBe('https://checkout.razorpay.com/v1/checkout.js'); - expect(result.current.isRazorpayLoaded).toBe(false); - - Object.defineProperty(window, 'Razorpay', { - configurable: true, - value: vi.fn(), - }); - act(() => { - script?.dispatchEvent(new Event('load')); - }); - - await waitFor(() => { - expect(result.current.isRazorpayLoaded).toBe(true); - expect(result.current.isRazorpayLoadFailed).toBe(false); - }); - }); -}); diff --git a/packages/react/src/components/checkout/payment/utils/use-load-razorpay.ts b/packages/react/src/components/checkout/payment/utils/use-load-razorpay.ts deleted file mode 100644 index e339232e..00000000 --- a/packages/react/src/components/checkout/payment/utils/use-load-razorpay.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect, useState } from 'react'; - -const RAZORPAY_SDK_ID = 'razorpay-sdk'; -const RAZORPAY_SDK_URL = 'https://checkout.razorpay.com/v1/checkout.js'; - -let isRazorpayLoaded = false; -let isRazorpayScriptRequested = false; -const listeners = new Set<(loaded: boolean, failed: boolean) => void>(); - -function hasRazorpayConstructor() { - return ( - typeof (window as Window & { Razorpay?: unknown }).Razorpay === 'function' - ); -} - -function notifyListeners(loaded: boolean, failed: boolean) { - listeners.forEach(listener => listener(loaded, failed)); -} - -export function useLoadRazorpay() { - const [loaded, setLoaded] = useState( - () => typeof window !== 'undefined' && hasRazorpayConstructor() - ); - const [failed, setFailed] = useState(false); - - useEffect(() => { - const updateState = (nextLoaded: boolean, nextFailed: boolean) => { - setLoaded(nextLoaded); - setFailed(nextFailed); - }; - listeners.add(updateState); - - if (isRazorpayLoaded || hasRazorpayConstructor()) { - isRazorpayLoaded = true; - updateState(true, false); - } - - return () => { - listeners.delete(updateState); - }; - }, []); - - useEffect(() => { - if (isRazorpayLoaded) return; - - const existingScript = document.getElementById( - RAZORPAY_SDK_ID - ) as HTMLScriptElement | null; - - if (existingScript) { - isRazorpayScriptRequested = true; - const handleLoad = () => { - isRazorpayLoaded = hasRazorpayConstructor(); - notifyListeners(isRazorpayLoaded, !isRazorpayLoaded); - }; - const handleError = () => { - isRazorpayScriptRequested = false; - notifyListeners(false, true); - }; - existingScript.addEventListener('load', handleLoad); - existingScript.addEventListener('error', handleError); - return () => { - existingScript.removeEventListener('load', handleLoad); - existingScript.removeEventListener('error', handleError); - }; - } - - if (isRazorpayScriptRequested) return; - - isRazorpayScriptRequested = true; - const script = document.createElement('script'); - script.id = RAZORPAY_SDK_ID; - script.src = RAZORPAY_SDK_URL; - script.async = true; - script.onload = () => { - isRazorpayLoaded = hasRazorpayConstructor(); - notifyListeners(isRazorpayLoaded, !isRazorpayLoaded); - }; - script.onerror = () => { - isRazorpayScriptRequested = false; - notifyListeners(false, true); - }; - document.body.appendChild(script); - }, []); - - return { - isRazorpayLoaded: loaded, - isRazorpayLoadFailed: failed, - }; -} diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index fd594cb6..41d1fd6e 100644 --- a/packages/react/src/lib/godaddy/checkout-mutations.ts +++ b/packages/react/src/lib/godaddy/checkout-mutations.ts @@ -121,6 +121,10 @@ export const CreateCheckoutSessionMutation = graphql(` processor checkoutTypes } + razorpay { + processor + checkoutTypes + } } draftOrder { id From 9a96e57527a0848ad91a1f0651b754c3447c6ae7 Mon Sep 17 00:00:00 2001 From: Dhruv Gupta Date: Wed, 16 Sep 2026 15:21:04 +0530 Subject: [PATCH 05/12] Fall back to session-provided PayPal config when no prop is supplied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkout-api now dynamically resolves PayPal's public SDK-bootstrap values (clientId/merchantId/partnerAttributionId/disableFunding) and persists them on the session as paymentProviderConfiguration.paypal (checkout-api PR #183, "Hosted Checkout Consumption"). Prefer an explicit paypalConfig prop (existing embedded-checkout integrations) and fall back to that session field otherwise, so the hosted checkout URL flow — where no prop is ever supplied — can initialize PayPal too. - Add paymentProviderConfiguration to both the create-session mutation and get-session query selection sets, and regenerate checkout-env.ts against checkout-api's current schema. - Add partnerAttributionId to PayPalConfig and wire it into the PayPal JS SDK's dataPartnerAttributionId option. - Don't offer PayPal as a payment method when neither source has a usable clientId, mirroring the existing GoDaddy CC/ACH gating pattern in payment-form.tsx's availablePaymentMethods. Co-Authored-By: Claude Sonnet 5 --- .changeset/paypal-session-config-fallback.md | 5 ++ .../__tests__/checkout-paypal-config.test.tsx | 68 +++++++++++++++ .../src/components/checkout/checkout.tsx | 27 +++++- .../checkout/payment/payment-form.tsx | 12 +++ .../utils/conditional-providers.test.tsx | 83 ++++++++++++++++++ .../payment/utils/conditional-providers.tsx | 6 ++ .../react/src/lib/godaddy/checkout-env.ts | 87 +++++++++++++++++++ .../src/lib/godaddy/checkout-mutations.ts | 8 ++ .../react/src/lib/godaddy/checkout-queries.ts | 8 ++ 9 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 .changeset/paypal-session-config-fallback.md create mode 100644 packages/react/src/components/checkout/__tests__/checkout-paypal-config.test.tsx create mode 100644 packages/react/src/components/checkout/payment/utils/conditional-providers.test.tsx diff --git a/.changeset/paypal-session-config-fallback.md b/.changeset/paypal-session-config-fallback.md new file mode 100644 index 00000000..31cf28eb --- /dev/null +++ b/.changeset/paypal-session-config-fallback.md @@ -0,0 +1,5 @@ +--- +'@godaddy/react': minor +--- + +PayPal's public SDK configuration (`clientId`, `merchantId`, `partnerAttributionId`, `disableFunding`) now falls back to `session.paymentProviderConfiguration.paypal` when no explicit `paypalConfig` prop is supplied, so hosted checkout can initialize PayPal without the embedding app passing it in. Also adds `partnerAttributionId` support (wired into the PayPal JS SDK's `dataPartnerAttributionId`) and no longer offers PayPal as a selectable payment method when no usable configuration is available from either source. diff --git a/packages/react/src/components/checkout/__tests__/checkout-paypal-config.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-paypal-config.test.tsx new file mode 100644 index 00000000..194d7433 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-paypal-config.test.tsx @@ -0,0 +1,68 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { PaymentProvider } from '@/types'; +import { renderCheckout, waitForCheckoutReady } from './checkout-test-env'; + +// ---------------------------------------------------------------------------- +// PayPal public configuration: prop vs. session fallback +// ---------------------------------------------------------------------------- +// +// checkout-api can now dynamically resolve PayPal's public SDK-bootstrap +// values (clientId/merchantId/partnerAttributionId) and persist them on the +// session as `paymentProviderConfiguration.paypal` (see checkout-api PR #183, +// "Hosted Checkout Consumption"). prefers an explicit `paypalConfig` +// prop (existing embedded-checkout integrations) and falls back to that +// session field otherwise (the hosted checkout URL flow, where no prop is +// ever supplied). A method must never be offered as usable when neither +// source has a usable clientId. +// ---------------------------------------------------------------------------- + +const paypalPaymentMethods = { + card: { + processor: PaymentProvider.GODADDY, + checkoutTypes: ['standard'], + }, + paypal: { + processor: PaymentProvider.PAYPAL, + checkoutTypes: ['standard'], + }, +}; + +describe('PayPal configuration: prop vs. session fallback', () => { + it('does not offer PayPal as a payment method when no config is available anywhere', async () => { + renderCheckout({ + sessionOverrides: { paymentMethods: paypalPaymentMethods }, + }); + await waitForCheckoutReady(); + + expect( + screen.queryByRole('button', { name: /paypal/i }) + ).not.toBeInTheDocument(); + }); + + it('offers PayPal when an explicit paypalConfig prop is supplied (embedded checkout)', async () => { + renderCheckout({ + sessionOverrides: { paymentMethods: paypalPaymentMethods }, + checkoutProps: { + paypalConfig: { clientId: 'prop-client-id' }, + }, + }); + await waitForCheckoutReady(); + + expect(screen.getByRole('button', { name: /paypal/i })).toBeInTheDocument(); + }); + + it('offers PayPal using session.paymentProviderConfiguration.paypal when no prop is supplied (hosted checkout)', async () => { + renderCheckout({ + sessionOverrides: { + paymentMethods: paypalPaymentMethods, + paymentProviderConfiguration: { + paypal: { clientId: 'session-client-id', merchantId: 'merchant-1' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect(screen.getByRole('button', { name: /paypal/i })).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index bf532b01..ccb97b20 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -82,6 +82,7 @@ export type SquareConfig = { export type PayPalConfig = { clientId: string; merchantId?: string; + partnerAttributionId?: string; disableFunding?: Array<'credit' | 'card' | 'paylater' | 'venmo'>; }; @@ -255,6 +256,30 @@ export function Checkout(props: CheckoutProps) { useTheme(session?.appearance?.theme); useVariables(session?.appearance?.variables || props?.appearance?.variables); + // Prefer an explicitly-supplied paypalConfig prop (existing embedded + // checkout integrations); fall back to the session's dynamically-resolved + // public configuration otherwise (hosted checkout, where no prop is ever + // supplied). See checkout-api PR #183's "Hosted Checkout Consumption". + // The GraphQL-sourced value uses `null` for absent optional fields; convert + // those to `undefined` so the result matches PayPalConfig exactly. + const sessionPayPalConfig = session?.paymentProviderConfiguration?.paypal; + const effectivePayPalConfig: PayPalConfig | undefined = + paypalConfig ?? + (sessionPayPalConfig + ? { + clientId: sessionPayPalConfig.clientId, + merchantId: sessionPayPalConfig.merchantId, + partnerAttributionId: + sessionPayPalConfig.partnerAttributionId ?? undefined, + // GraphQL exposes this as a plain string list; checkout-api + // validates the values against PayPalDisableFundingSchema before + // ever persisting them, so this narrowing is safe. + disableFunding: (sessionPayPalConfig.disableFunding ?? undefined) as + | PayPalConfig['disableFunding'] + | undefined, + } + : undefined); + const validationMessages = React.useMemo( () => ({ enterValidBillingPhone: t.validation.enterValidBillingPhone, @@ -331,7 +356,7 @@ export function Checkout(props: CheckoutProps) { godaddyPaymentsConfig, squareConfig, mercadoPagoConfig, - paypalConfig, + paypalConfig: effectivePayPalConfig, ccavenueConfig, requiredFields, isConfirmingCheckout, diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx index 18d6523d..10fa22ef 100644 --- a/packages/react/src/components/checkout/payment/payment-form.tsx +++ b/packages/react/src/components/checkout/payment/payment-form.tsx @@ -108,6 +108,7 @@ export function PaymentForm( setCheckoutErrors, requiredFields, godaddyPaymentsConfig, + paypalConfig, } = useCheckoutContext(); const form = useFormContext(); const paymentMethod = form.watch('paymentMethod'); @@ -284,6 +285,16 @@ export function PaymentForm( return baseCheck && hasGoDaddyAppId; } + // PayPal requires public SDK configuration (clientId at minimum) to + // initialize the JS SDK. Without it, the button would render a visible + // "configuration missing" error instead of a usable payment option. + if ( + key === PaymentMethodType.PAYPAL && + method?.processor === PaymentProvider.PAYPAL + ) { + return baseCheck && !!paypalConfig?.clientId?.trim(); + } + // Special handling for GoDaddy wallet payments — only show when device supports them if ( key === PaymentMethodType.PAZE && @@ -314,6 +325,7 @@ export function PaymentForm( pazeSupported, applePaySupported, googlePaySupported, + paypalConfig?.clientId, ]); const shouldShowBilling = diff --git a/packages/react/src/components/checkout/payment/utils/conditional-providers.test.tsx b/packages/react/src/components/checkout/payment/utils/conditional-providers.test.tsx new file mode 100644 index 00000000..8835458b --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/conditional-providers.test.tsx @@ -0,0 +1,83 @@ +import { render } from '@testing-library/react'; +import type React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PayPalConfig } from '@/components/checkout/checkout'; +import { checkoutContext } from '@/components/checkout/checkout'; + +const mockScriptProviderOptions = vi.fn(); + +vi.mock('@paypal/react-paypal-js', () => ({ + PayPalScriptProvider: ({ + options, + children, + }: { + options: unknown; + children: React.ReactNode; + }) => { + mockScriptProviderOptions(options); + return children; + }, +})); + +vi.mock('./use-build-payment-request', () => ({ + useBuildPaymentRequest: () => ({ payPalRequest: undefined }), +})); + +// vi.mock calls above are hoisted, so this picks up the mocked modules. +import { ConditionalPaymentProviders } from './conditional-providers'; + +function renderWithPayPalConfig(paypalConfig?: PayPalConfig) { + return render( + { + // no-op for this test + }, + setCheckoutErrors: () => { + // no-op for this test + }, + }} + > + +
+ + + ); +} + +describe('ConditionalPaymentProviders — PayPal SDK options', () => { + beforeEach(() => { + mockScriptProviderOptions.mockClear(); + }); + + it('passes dataPartnerAttributionId through to the PayPal JS SDK when present', () => { + renderWithPayPalConfig({ + clientId: 'client-1', + merchantId: 'merchant-1', + partnerAttributionId: 'GoDaddy_SP', + }); + + expect(mockScriptProviderOptions).toHaveBeenCalledWith( + expect.objectContaining({ dataPartnerAttributionId: 'GoDaddy_SP' }) + ); + }); + + it('omits dataPartnerAttributionId when not configured', () => { + renderWithPayPalConfig({ clientId: 'client-1' }); + + expect(mockScriptProviderOptions).toHaveBeenCalledTimes(1); + const options = mockScriptProviderOptions.mock.calls[0][0] as Record< + string, + unknown + >; + expect(options).not.toHaveProperty('dataPartnerAttributionId'); + }); + + it('does not load the PayPal SDK at all when clientId is missing', () => { + renderWithPayPalConfig(undefined); + + expect(mockScriptProviderOptions).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/components/checkout/payment/utils/conditional-providers.tsx b/packages/react/src/components/checkout/payment/utils/conditional-providers.tsx index 8d6c9292..6529e67a 100644 --- a/packages/react/src/components/checkout/payment/utils/conditional-providers.tsx +++ b/packages/react/src/components/checkout/payment/utils/conditional-providers.tsx @@ -81,6 +81,12 @@ export function ConditionalPaymentProviders({ ...(paypalConfig.merchantId?.trim() ? { merchantId: paypalConfig.merchantId.trim() } : {}), + ...(paypalConfig.partnerAttributionId?.trim() + ? { + dataPartnerAttributionId: + paypalConfig.partnerAttributionId.trim(), + } + : {}), currency: payPalRequest?.purchase_units?.[0]?.amount?.currency_code || 'USD', intent: 'capture', diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index ae7b7878..1ad4022c 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -2029,6 +2029,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "paymentProviderConfiguration", + "type": { + "kind": "OBJECT", + "name": "CheckoutSessionPaymentProviderConfiguration" + }, + "args": [], + "isDeprecated": false + }, { "name": "returnUrl", "type": { @@ -3434,6 +3443,61 @@ const introspection = { ], "isOneOf": false }, + { + "kind": "OBJECT", + "name": "CheckoutSessionPayPalProviderConfiguration", + "fields": [ + { + "name": "clientId", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "disableFunding", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "merchantId", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "partnerAttributionId", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "CheckoutSessionPaymentMethodConfig", @@ -3710,6 +3774,22 @@ const introspection = { ], "isOneOf": false }, + { + "kind": "OBJECT", + "name": "CheckoutSessionPaymentProviderConfiguration", + "fields": [ + { + "name": "paypal", + "type": { + "kind": "OBJECT", + "name": "CheckoutSessionPayPalProviderConfiguration" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "CheckoutSessionShippingOptions", @@ -8057,6 +8137,13 @@ const introspection = { "name": "CheckoutSessionOperatingHoursMapInput" } }, + { + "name": "owner", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, { "name": "paymentMethods", "type": { diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index fd594cb6..d5da96c5 100644 --- a/packages/react/src/lib/godaddy/checkout-mutations.ts +++ b/packages/react/src/lib/godaddy/checkout-mutations.ts @@ -122,6 +122,14 @@ export const CreateCheckoutSessionMutation = graphql(` checkoutTypes } } + paymentProviderConfiguration { + paypal { + clientId + merchantId + partnerAttributionId + disableFunding + } + } draftOrder { id statuses { diff --git a/packages/react/src/lib/godaddy/checkout-queries.ts b/packages/react/src/lib/godaddy/checkout-queries.ts index b1f93a92..f5f396f7 100644 --- a/packages/react/src/lib/godaddy/checkout-queries.ts +++ b/packages/react/src/lib/godaddy/checkout-queries.ts @@ -143,6 +143,14 @@ export const GetCheckoutSessionQuery = graphql(` checkoutTypes } } + paymentProviderConfiguration { + paypal { + clientId + merchantId + partnerAttributionId + disableFunding + } + } locations { id isDefault From 15ec024af056e62272bc440192efe566b522899f Mon Sep 17 00:00:00 2001 From: Dhruv Gupta Date: Wed, 16 Sep 2026 20:15:35 +0530 Subject: [PATCH 06/12] patch: updated changeset --- .changeset/paypal-session-config-fallback.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/paypal-session-config-fallback.md b/.changeset/paypal-session-config-fallback.md index 31cf28eb..9d366b05 100644 --- a/.changeset/paypal-session-config-fallback.md +++ b/.changeset/paypal-session-config-fallback.md @@ -1,5 +1,5 @@ --- -'@godaddy/react': minor +'@godaddy/react': patch --- PayPal's public SDK configuration (`clientId`, `merchantId`, `partnerAttributionId`, `disableFunding`) now falls back to `session.paymentProviderConfiguration.paypal` when no explicit `paypalConfig` prop is supplied, so hosted checkout can initialize PayPal without the embedding app passing it in. Also adds `partnerAttributionId` support (wired into the PayPal JS SDK's `dataPartnerAttributionId`) and no longer offers PayPal as a selectable payment method when no usable configuration is available from either source. From cd641124327306d1c05c58fc3f8c6b0b1f5aff5b Mon Sep 17 00:00:00 2001 From: pchawla-godaddy Date: Thu, 17 Sep 2026 13:05:48 +0530 Subject: [PATCH 07/12] Fix Razorpay payment lifecycle edge cases Co-authored-by: Cursor --- .../razorpay/razorpay.test.tsx | 52 +++++++++++----- .../checkout-buttons/razorpay/razorpay.tsx | 62 +++++++++---------- .../utils/razorpay-loader-context.test.tsx | 59 +++++++++++++++++- .../payment/utils/razorpay-loader-context.tsx | 59 ++++++++++++++++-- 4 files changed, 175 insertions(+), 57 deletions(-) diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx index 8b5f72cd..55287775 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx @@ -7,10 +7,7 @@ import { } from '@testing-library/react'; import type { ReactNode } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - RAZORPAY_CALLBACK_TIMEOUT_MS, - RazorpayCheckoutButton, -} from './razorpay'; +import { RazorpayCheckoutButton } from './razorpay'; const mocks = vi.hoisted(() => ({ authorize: vi.fn(), @@ -80,13 +77,15 @@ type CapturedOptions = { razorpay_order_id?: string; razorpay_signature?: string; }) => void; + modal: { + ondismiss: () => void; + }; }; describe('RazorpayCheckoutButton', () => { let capturedOptions: CapturedOptions | undefined; let paymentFailedHandler: (() => void) | undefined; const open = vi.fn(); - const close = vi.fn(); const on = vi.fn((event: string, handler: () => void) => { if (event === 'payment.failed') paymentFailedHandler = handler; }); @@ -126,7 +125,6 @@ describe('RazorpayCheckoutButton', () => { } open = open; - close = close; on = on; }, }); @@ -225,6 +223,33 @@ describe('RazorpayCheckoutButton', () => { expect(capturedOptions?.amount).toBe(4200); }); + it('allows only one payment attempt while validation is pending', async () => { + let resolveValidation: ((valid: boolean) => void) | undefined; + mocks.trigger.mockImplementation( + () => + new Promise(resolve => { + resolveValidation = resolve; + }) + ); + + render(); + const payButton = screen.getByRole('button', { name: 'Pay now' }); + fireEvent.click(payButton); + fireEvent.click(payButton); + + expect(mocks.trigger).toHaveBeenCalledOnce(); + expect(mocks.flush).not.toHaveBeenCalled(); + expect(mocks.authorize).not.toHaveBeenCalled(); + + await act(async () => { + resolveValidation?.(true); + }); + + await waitFor(() => expect(open).toHaveBeenCalledOnce()); + expect(mocks.flush).toHaveBeenCalledOnce(); + expect(mocks.authorize).toHaveBeenCalledOnce(); + }); + it('does not confirm when Razorpay reports a failed payment', async () => { render(); fireEvent.click(screen.getByRole('button', { name: 'Pay now' })); @@ -269,23 +294,16 @@ describe('RazorpayCheckoutButton', () => { } ); - it('closes Checkout when no callback arrives within two minutes', async () => { + it('does not impose a client timeout on an active Checkout session', async () => { const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); render(); fireEvent.click(screen.getByRole('button', { name: 'Pay now' })); await waitFor(() => expect(open).toHaveBeenCalledOnce()); - const timeoutCall = setTimeoutSpy.mock.calls.find( - ([, delay]) => delay === RAZORPAY_CALLBACK_TIMEOUT_MS + expect(setTimeoutSpy).not.toHaveBeenCalledWith( + expect.any(Function), + 2 * 60 * 1000 ); - expect(timeoutCall).toBeDefined(); - - act(() => { - (timeoutCall?.[0] as () => void)(); - }); - - expect(close).toHaveBeenCalledOnce(); expect(mocks.confirm).not.toHaveBeenCalled(); - expect(screen.getByText('Error processing payment')).toBeTruthy(); }); }); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx index 164d9c04..2edb0db2 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx @@ -1,5 +1,5 @@ import { LoaderCircle } from 'lucide-react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { @@ -20,8 +20,6 @@ import { useGoDaddyContext } from '@/godaddy-provider'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { PaymentMethodType } from '@/types'; -export const RAZORPAY_CALLBACK_TIMEOUT_MS = 2 * 60 * 1000; - type RazorpaySuccessResponse = { razorpay_payment_id?: string; razorpay_order_id?: string; @@ -48,7 +46,6 @@ type RazorpayOptions = { type RazorpayInstance = { open: () => void; - close: () => void; on: (event: 'payment.failed', handler: (response: unknown) => void) => void; }; @@ -77,27 +74,24 @@ function RazorpayCheckoutButtonInner() { const isPaymentDisabled = useIsPaymentDisabled(); const { isRazorpayLoaded, isRazorpayLoadFailed } = useRazorpayLoader(); const [isWidgetOpen, setIsWidgetOpen] = useState(false); + const [isAttempting, setIsAttempting] = useState(false); const [error, setError] = useState(''); - const callbackTimeoutRef = useRef | null>(null); + const attemptLockRef = useRef(false); - const clearCallbackTimeout = useCallback(() => { - if (callbackTimeoutRef.current) { - clearTimeout(callbackTimeoutRef.current); - callbackTimeoutRef.current = null; - } + const releasePaymentAttempt = useCallback(() => { + attemptLockRef.current = false; + setIsAttempting(false); }, []); - useEffect(() => clearCallbackTimeout, [clearCallbackTimeout]); - const handlePaymentSuccess = useCallback( async (response: RazorpaySuccessResponse) => { - clearCallbackTimeout(); const paymentId = response.razorpay_payment_id; const orderId = response.razorpay_order_id; const signature = response.razorpay_signature; if (!paymentId || !orderId || !signature) { setError(t.errors.errorProcessingPayment); setIsWidgetOpen(false); + releasePaymentAttempt(); return; } @@ -121,11 +115,12 @@ function RazorpayCheckoutButtonInner() { } } finally { setIsWidgetOpen(false); + releasePaymentAttempt(); } }, [ confirmCheckout, - clearCallbackTimeout, + releasePaymentAttempt, setCheckoutErrors, t.errors.errorProcessingPayment, ] @@ -136,21 +131,29 @@ function RazorpayCheckoutButtonInner() { }, [t.errors.errorProcessingPayment]); const handleClick = async () => { - if (isWidgetOpen || authorizeCheckout.isPending || isConfirmingCheckout) { + if ( + attemptLockRef.current || + isWidgetOpen || + authorizeCheckout.isPending || + isConfirmingCheckout + ) { return; } + attemptLockRef.current = true; + setIsAttempting(true); - const valid = await form.trigger(); - if (!valid) { - const firstError = Object.keys(form.formState.errors)[0]; - if (firstError) form.setFocus(firstError); - return; - } + try { + const valid = await form.trigger(); + if (!valid) { + const firstError = Object.keys(form.formState.errors)[0]; + if (firstError) form.setFocus(firstError); + releasePaymentAttempt(); + return; + } - setCheckoutErrors(undefined); - setError(''); + setCheckoutErrors(undefined); + setError(''); - try { const { latestOrder } = await flushCheckoutSync({ includeCurrentFormDiff: true, }); @@ -203,24 +206,18 @@ function RazorpayCheckoutButtonInner() { }, modal: { ondismiss: () => { - clearCallbackTimeout(); setIsWidgetOpen(false); + releasePaymentAttempt(); }, }, }); widget.on('payment.failed', handlePaymentFailure); setIsWidgetOpen(true); - callbackTimeoutRef.current = setTimeout(() => { - callbackTimeoutRef.current = null; - widget.close(); - setIsWidgetOpen(false); - setError(t.errors.errorProcessingPayment); - }, RAZORPAY_CALLBACK_TIMEOUT_MS); widget.open(); } catch (err: unknown) { - clearCallbackTimeout(); setIsWidgetOpen(false); + releasePaymentAttempt(); if (err instanceof GraphQLErrorWithCodes) { setCheckoutErrors(err.codes); } else { @@ -231,6 +228,7 @@ function RazorpayCheckoutButtonInner() { const isBusy = isPaymentDisabled || + isAttempting || isWidgetOpen || authorizeCheckout.isPending || isConfirmingCheckout; diff --git a/packages/react/src/components/checkout/payment/utils/razorpay-loader-context.test.tsx b/packages/react/src/components/checkout/payment/utils/razorpay-loader-context.test.tsx index 7043b05e..4c855b9d 100644 --- a/packages/react/src/components/checkout/payment/utils/razorpay-loader-context.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/razorpay-loader-context.test.tsx @@ -59,7 +59,7 @@ describe('RazorpayLoaderProvider', () => { ); }); - it('treats a load without the Razorpay constructor as a failure', () => { + it('retries when the script loads without the Razorpay constructor', () => { render( @@ -70,9 +70,15 @@ describe('RazorpayLoaderProvider', () => { getScript()?.dispatchEvent(new Event('load')); }); + expect(getScript()).toBeNull(); expect(screen.getByTestId('probe').textContent).toBe( - 'loaded:false failed:true' + 'loaded:false failed:false' ); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(getScript()?.dataset.status).toBe('loading'); }); it('retries loading the SDK on script error before giving up', () => { @@ -199,5 +205,54 @@ describe('RazorpayLoaderProvider', () => { }); expect(getScript()).not.toBeNull(); + expect(getScript()?.dataset.status).toBe('failed'); + }); + + it('replaces a failed script tag when the provider remounts', () => { + const { unmount } = render( + + + + ); + const failedScript = getScript(); + unmount(); + + act(() => { + failedScript?.dispatchEvent(new Event('error')); + }); + expect(failedScript?.dataset.status).toBe('failed'); + + render( + + + + ); + + expect(getScript()).not.toBe(failedScript); + expect(getScript()?.dataset.status).toBe('loading'); + }); + + it('replaces a constructor-less loaded script when the provider remounts', () => { + const { unmount } = render( + + + + ); + const failedScript = getScript(); + unmount(); + + act(() => { + failedScript?.dispatchEvent(new Event('load')); + }); + expect(failedScript?.dataset.status).toBe('failed'); + + render( + + + + ); + + expect(getScript()).not.toBe(failedScript); + expect(getScript()?.dataset.status).toBe('loading'); }); }); diff --git a/packages/react/src/components/checkout/payment/utils/razorpay-loader-context.tsx b/packages/react/src/components/checkout/payment/utils/razorpay-loader-context.tsx index bfa86573..9a750ca0 100644 --- a/packages/react/src/components/checkout/payment/utils/razorpay-loader-context.tsx +++ b/packages/react/src/components/checkout/payment/utils/razorpay-loader-context.tsx @@ -12,6 +12,8 @@ const RAZORPAY_SDK_URL = 'https://checkout.razorpay.com/v1/checkout.js'; const MAX_LOAD_RETRIES = 3; const RETRY_DELAY_MS = 1000; +type RazorpayScriptStatus = 'loading' | 'loaded' | 'failed'; + type RazorpayLoaderState = { isRazorpayLoaded: boolean; isRazorpayLoadFailed: boolean; @@ -28,6 +30,39 @@ function hasRazorpayConstructor() { ); } +function setScriptStatus( + script: HTMLScriptElement, + status: RazorpayScriptStatus +) { + script.dataset.status = status; +} + +function getScriptStatus( + script: HTMLScriptElement +): RazorpayScriptStatus | undefined { + const status = script.dataset.status; + return status === 'loading' || status === 'loaded' || status === 'failed' + ? status + : undefined; +} + +function trackScriptStatus(script: HTMLScriptElement) { + script.addEventListener( + 'load', + () => { + setScriptStatus(script, hasRazorpayConstructor() ? 'loaded' : 'failed'); + }, + { once: true } + ); + script.addEventListener( + 'error', + () => { + setScriptStatus(script, 'failed'); + }, + { once: true } + ); +} + export function RazorpayLoaderProvider({ children }: { children: ReactNode }) { const [state, setState] = useState(() => ({ isRazorpayLoaded: typeof window !== 'undefined' && hasRazorpayConstructor(), @@ -48,11 +83,12 @@ export function RazorpayLoaderProvider({ children }: { children: ReactNode }) { const handleLoad = () => { if (cancelled) return; + if (!hasRazorpayConstructor()) { + handleError(); + return; + } retryCountRef.current = 0; - // A load event without the constructor means the SDK did not install - // itself, which is a failure rather than a usable Checkout. - const loaded = hasRazorpayConstructor(); - setState({ isRazorpayLoaded: loaded, isRazorpayLoadFailed: !loaded }); + setState({ isRazorpayLoaded: true, isRazorpayLoadFailed: false }); }; const handleError = () => { @@ -92,14 +128,25 @@ export function RazorpayLoaderProvider({ children }: { children: ReactNode }) { RAZORPAY_SDK_ID ) as HTMLScriptElement | null; if (existingScript) { - attachListeners(existingScript); - return; + const status = getScriptStatus(existingScript); + if (status === 'loading') { + attachListeners(existingScript); + return; + } + + // A completed, failed, or untracked tag cannot emit another useful + // event. Replace it instead of leaving a remounted provider waiting. + existingScript.remove(); } const script = document.createElement('script'); script.id = RAZORPAY_SDK_ID; script.src = RAZORPAY_SDK_URL; script.async = true; + setScriptStatus(script, 'loading'); + // These listeners intentionally survive provider unmounts so the tag + // records its outcome for a later remount. + trackScriptStatus(script); attachListeners(script); document.body.appendChild(script); } From 662a2a85b95cb018c2743d234ad5d9519176ada5 Mon Sep 17 00:00:00 2001 From: pchawla-godaddy Date: Wed, 23 Sep 2026 12:58:29 +0530 Subject: [PATCH 08/12] Fix Razorpay lifecycle transitions Co-authored-by: Cursor --- .../razorpay/razorpay.test.tsx | 105 ++++++++++++++++++ .../checkout-buttons/razorpay/razorpay.tsx | 56 ++++++++-- 2 files changed, 149 insertions(+), 12 deletions(-) diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx index 55287775..2ce7f227 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.test.tsx @@ -192,6 +192,45 @@ describe('RazorpayCheckoutButton', () => { }); }); + it('preserves a transport error when confirmation unmounts the button', async () => { + let rejectConfirmation: ((reason: Error) => void) | undefined; + mocks.confirm.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectConfirmation = reject; + }) + ); + const view = render(); + + fireEvent.click(screen.getByRole('button', { name: 'Pay now' })); + await waitFor(() => expect(open).toHaveBeenCalledOnce()); + + act(() => { + capturedOptions?.handler({ + razorpay_payment_id: 'pay_razorpay_456', + razorpay_order_id: 'order_razorpay_123', + razorpay_signature: 'signature_789', + }); + }); + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce()); + + // PaymentForm replaces the provider button while confirmation is pending. + view.unmount(); + await act(async () => { + rejectConfirmation?.(new Error('Network unavailable')); + }); + + expect(mocks.setCheckoutErrors).toHaveBeenCalledWith([ + 'TRANSACTION_PROCESSING_FAILED', + ]); + + // The checkout-level error survives when PaymentForm mounts the button again. + render(); + expect(mocks.setCheckoutErrors).toHaveBeenCalledWith([ + 'TRANSACTION_PROCESSING_FAILED', + ]); + }); + it('waits for the checkout sync to settle before authorizing', async () => { let resolveFlush: ((value: unknown) => void) | undefined; mocks.flush.mockImplementation( @@ -250,6 +289,72 @@ describe('RazorpayCheckoutButton', () => { expect(mocks.authorize).toHaveBeenCalledOnce(); }); + it('does not open Checkout when authorization resolves after unmount', async () => { + let resolveAuthorization: ((value: unknown) => void) | undefined; + mocks.authorize.mockImplementation( + () => + new Promise(resolve => { + resolveAuthorization = resolve; + }) + ); + const view = render(); + + fireEvent.click(screen.getByRole('button', { name: 'Pay now' })); + await waitFor(() => expect(mocks.authorize).toHaveBeenCalledOnce()); + view.unmount(); + + await act(async () => { + resolveAuthorization?.({ + fundingSource: { paymentReference: 'order_stale_123' }, + references: [{ type: 'MERCHANT_PUBLIC_KEY', value: 'rzp_test_stale' }], + }); + }); + + expect(open).not.toHaveBeenCalled(); + expect(capturedOptions).toBeUndefined(); + }); + + it('keeps a remounted attempt isolated from stale authorization', async () => { + let resolveFirstAuthorization: ((value: unknown) => void) | undefined; + mocks.authorize + .mockImplementationOnce( + () => + new Promise(resolve => { + resolveFirstAuthorization = resolve; + }) + ) + .mockResolvedValueOnce({ + fundingSource: { paymentReference: 'order_fresh_456' }, + references: [{ type: 'MERCHANT_PUBLIC_KEY', value: 'rzp_test_fresh' }], + }); + const firstView = render(); + + fireEvent.click(screen.getByRole('button', { name: 'Pay now' })); + await waitFor(() => expect(mocks.authorize).toHaveBeenCalledOnce()); + firstView.unmount(); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Pay now' })); + await waitFor(() => expect(open).toHaveBeenCalledOnce()); + expect(capturedOptions).toMatchObject({ + key: 'rzp_test_fresh', + order_id: 'order_fresh_456', + }); + + await act(async () => { + resolveFirstAuthorization?.({ + fundingSource: { paymentReference: 'order_stale_123' }, + references: [{ type: 'MERCHANT_PUBLIC_KEY', value: 'rzp_test_stale' }], + }); + }); + + expect(open).toHaveBeenCalledOnce(); + expect(capturedOptions).toMatchObject({ + key: 'rzp_test_fresh', + order_id: 'order_fresh_456', + }); + }); + it('does not confirm when Razorpay reports a failed payment', async () => { render(); fireEvent.click(screen.getByRole('button', { name: 'Pay now' })); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx index 2edb0db2..a6bb5982 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/razorpay/razorpay.tsx @@ -1,5 +1,5 @@ import { LoaderCircle } from 'lucide-react'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { @@ -77,21 +77,42 @@ function RazorpayCheckoutButtonInner() { const [isAttempting, setIsAttempting] = useState(false); const [error, setError] = useState(''); const attemptLockRef = useRef(false); + const attemptIdRef = useRef(0); + const isMountedRef = useRef(false); - const releasePaymentAttempt = useCallback(() => { + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + attemptIdRef.current += 1; + attemptLockRef.current = false; + }; + }, []); + + const isCurrentAttempt = useCallback( + (attemptId: number) => + isMountedRef.current && attemptIdRef.current === attemptId, + [] + ); + + const releasePaymentAttempt = useCallback((attemptId: number) => { + if (!isMountedRef.current || attemptIdRef.current !== attemptId) return; attemptLockRef.current = false; setIsAttempting(false); }, []); const handlePaymentSuccess = useCallback( - async (response: RazorpaySuccessResponse) => { + async (response: RazorpaySuccessResponse, attemptId: number) => { + if (!isCurrentAttempt(attemptId)) return; + const paymentId = response.razorpay_payment_id; const orderId = response.razorpay_order_id; const signature = response.razorpay_signature; if (!paymentId || !orderId || !signature) { setError(t.errors.errorProcessingPayment); setIsWidgetOpen(false); - releasePaymentAttempt(); + releasePaymentAttempt(attemptId); return; } @@ -111,15 +132,18 @@ function RazorpayCheckoutButtonInner() { if (err instanceof GraphQLErrorWithCodes) { setCheckoutErrors(err.codes); } else { - setError(t.errors.errorProcessingPayment); + setCheckoutErrors(['TRANSACTION_PROCESSING_FAILED']); } } finally { - setIsWidgetOpen(false); - releasePaymentAttempt(); + if (isCurrentAttempt(attemptId)) { + setIsWidgetOpen(false); + releasePaymentAttempt(attemptId); + } } }, [ confirmCheckout, + isCurrentAttempt, releasePaymentAttempt, setCheckoutErrors, t.errors.errorProcessingPayment, @@ -139,15 +163,17 @@ function RazorpayCheckoutButtonInner() { ) { return; } + const attemptId = ++attemptIdRef.current; attemptLockRef.current = true; setIsAttempting(true); try { const valid = await form.trigger(); + if (!isCurrentAttempt(attemptId)) return; if (!valid) { const firstError = Object.keys(form.formState.errors)[0]; if (firstError) form.setFocus(firstError); - releasePaymentAttempt(); + releasePaymentAttempt(attemptId); return; } @@ -157,6 +183,7 @@ function RazorpayCheckoutButtonInner() { const { latestOrder } = await flushCheckoutSync({ includeCurrentFormDiff: true, }); + if (!isCurrentAttempt(attemptId)) return; const total = latestOrder?.totals?.total; if (!latestOrder?.id || total?.value == null || !total.currencyCode) { throw new Error('Synchronized draft order is unavailable'); @@ -175,6 +202,7 @@ function RazorpayCheckoutButtonInner() { paymentType: PaymentMethodType.RAZORPAY, paymentProvider: PaymentProvider.RAZORPAY, }); + if (!isCurrentAttempt(attemptId)) return; const orderId = authorization?.fundingSource?.paymentReference; const publicToken = authorization?.references?.find( reference => reference.type === 'MERCHANT_PUBLIC_KEY' @@ -202,22 +230,26 @@ function RazorpayCheckoutButtonInner() { contact: buyerPhone, }, handler: response => { - void handlePaymentSuccess(response); + void handlePaymentSuccess(response, attemptId); }, modal: { ondismiss: () => { + if (!isCurrentAttempt(attemptId)) return; setIsWidgetOpen(false); - releasePaymentAttempt(); + releasePaymentAttempt(attemptId); }, }, }); - widget.on('payment.failed', handlePaymentFailure); + widget.on('payment.failed', () => { + if (isCurrentAttempt(attemptId)) handlePaymentFailure(); + }); setIsWidgetOpen(true); widget.open(); } catch (err: unknown) { + if (!isCurrentAttempt(attemptId)) return; setIsWidgetOpen(false); - releasePaymentAttempt(); + releasePaymentAttempt(attemptId); if (err instanceof GraphQLErrorWithCodes) { setCheckoutErrors(err.codes); } else { From db8bb6919104977c55a14bf93463e058877d3a01 Mon Sep 17 00:00:00 2001 From: Dhruv Gupta Date: Fri, 25 Sep 2026 16:17:29 +0530 Subject: [PATCH 09/12] debug: show PayPal button and Pay now action when paymentProviderConfiguration.paypal exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session paymentMethods.paypal can be null while paymentProviderConfiguration.paypal is populated (session created with explicit paymentMethods input, discovery still resolves the provider config). Both the accordion's availablePaymentMethods filter and useGetSelectedPaymentMethod's consumer in payment-form.tsx now fall back to a synthesized standard PayPal method config, gated on paypalConfig?.clientId actually being present, so this stays testing-only and never fires for a real absent config. TEMP FOR TESTING — DO NOT COMMIT to a real release; must be reverted. Co-Authored-By: Claude Sonnet 5 --- .../checkout/payment/payment-form.tsx | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx index 1a270da3..67e114f6 100644 --- a/packages/react/src/components/checkout/payment/payment-form.tsx +++ b/packages/react/src/components/checkout/payment/payment-form.tsx @@ -121,9 +121,27 @@ export function PaymentForm( paymentMethod === PaymentMethodType.ACH; const canOfferShippingAddressAsBilling = useCanOfferShippingAddressAsBilling(); - const methodConfig = useGetSelectedPaymentMethod( + const rawMethodConfig = useGetSelectedPaymentMethod( paymentMethod as PaymentMethodValue ); + // TEMP FOR TESTING — DO NOT COMMIT: mirrors the availablePaymentMethods + // bypass below. session.paymentMethods.paypal can be null even when PayPal + // is actually configured (only paymentProviderConfiguration.paypal gets + // resolved by discovery on a session created with explicit paymentMethods + // input). Without this, useGetSelectedPaymentMethod returns null for + // PayPal, so getCheckoutButton() bails out at `if (!methodConfig) return + // null` and the "Pay now" button never renders — even though PayPal is + // selectable in the accordion thanks to the bypass below. + const methodConfig = + rawMethodConfig ?? + (paymentMethod === PaymentMethodType.PAYPAL && + paypalConfig?.clientId?.trim() + ? { + type: PaymentMethodType.PAYPAL as PaymentMethodValue, + processor: PaymentProvider.PAYPAL, + checkoutTypes: [CheckoutType.STANDARD], + } + : null); const { isPoyntLoaded } = useLoadPoyntCollect(); const [pazeSupported, setPazeSupported] = useState(null); @@ -264,11 +282,25 @@ export function PaymentForm( return Object.keys(configuredPaymentMethods).filter(key => { const method = configuredPaymentMethods[key as PaymentMethodValue]; + // TEMP FOR TESTING — DO NOT COMMIT: session.paymentMethods.paypal can + // be null on a session created with explicit paymentMethods input + // (only paymentProviderConfiguration gets resolved by discovery in + // that case). Only treat a null paypal method as "standard" when real + // PayPal SDK config actually exists — not a blind pretend, gated on + // real data. (No equivalent exists for Razorpay: its public config is + // never delivered via paymentProviderConfiguration by design — see + // razorpay-resolver.ts — so there's nothing to check it against here.) + const isPayPalWithRealConfig = + key === PaymentMethodType.PAYPAL && !!paypalConfig?.clientId?.trim(); + const effectiveCheckoutTypes = + method?.checkoutTypes ?? + (isPayPalWithRealConfig ? [CheckoutType.STANDARD] : undefined); + const baseCheck = PAYMENT_METHOD_ICONS[key as PaymentMethodValue] && - method && - Array.isArray(method.checkoutTypes) && - method.checkoutTypes.includes(CheckoutType.STANDARD); + (method || isPayPalWithRealConfig) && + Array.isArray(effectiveCheckoutTypes) && + effectiveCheckoutTypes.includes(CheckoutType.STANDARD); // Match the business ID requirement used by Collect and its providers. if ( @@ -288,9 +320,11 @@ export function PaymentForm( // PayPal requires public SDK configuration (clientId at minimum) to // initialize the JS SDK. Without it, the button would render a visible // "configuration missing" error instead of a usable payment option. + // (method is null in the TEMP testing case above, so also allow + // through when method is missing but the key matches.) if ( key === PaymentMethodType.PAYPAL && - method?.processor === PaymentProvider.PAYPAL + (method?.processor === PaymentProvider.PAYPAL || !method) ) { return baseCheck && !!paypalConfig?.clientId?.trim(); } From aff71198c0e2b9861821ef6bd752cc8299be9221 Mon Sep 17 00:00:00 2001 From: Rohan Sharma Date: Fri, 25 Sep 2026 19:40:02 +0530 Subject: [PATCH 10/12] Gate Razorpay checkout button on paymentProviderConfiguration.razorpay.configured Add razorpay { configured } to the checkout session GraphQL queries/mutations and hand-edit checkout-env.ts to add the corresponding schema type, mirroring the existing PayPal provider-config pattern. Expose it via checkoutContext as razorpayConfig (an object, not a bare boolean, so future fields don't require a signature change) and require it in payment-form's availablePaymentMethods gating before ever offering the Razorpay button. Co-Authored-By: Claude Sonnet 5 --- .../checkout-razorpay-config.test.tsx | 53 +++++++++++++++++++ .../__tests__/checkout-razorpay.test.tsx | 3 ++ .../src/components/checkout/checkout.tsx | 12 +++++ .../checkout/payment/payment-form.tsx | 18 +++++-- .../react/src/lib/godaddy/checkout-env.ts | 28 ++++++++++ .../src/lib/godaddy/checkout-mutations.ts | 3 ++ .../react/src/lib/godaddy/checkout-queries.ts | 3 ++ 7 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 packages/react/src/components/checkout/__tests__/checkout-razorpay-config.test.tsx diff --git a/packages/react/src/components/checkout/__tests__/checkout-razorpay-config.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-razorpay-config.test.tsx new file mode 100644 index 00000000..b3d39e72 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-razorpay-config.test.tsx @@ -0,0 +1,53 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { renderCheckout, waitForCheckoutReady } from './checkout-test-env'; + +const razorpayPaymentMethods = { + card: { processor: 'godaddy', checkoutTypes: ['standard'] }, + razorpay: { + type: 'razorpay', + processor: 'razorpay', + checkoutTypes: ['standard'], + }, +}; + +describe('Razorpay configuration gating', () => { + it('hides Razorpay when paymentProviderConfiguration.razorpay is missing', async () => { + renderCheckout({ + sessionOverrides: { paymentMethods: razorpayPaymentMethods } as never, + }); + await waitForCheckoutReady(); + + expect( + screen.queryByRole('button', { name: /razorpay/i }) + ).not.toBeInTheDocument(); + }); + + it('hides Razorpay when configured is false', async () => { + renderCheckout({ + sessionOverrides: { + paymentMethods: razorpayPaymentMethods, + paymentProviderConfiguration: { razorpay: { configured: false } }, + } as never, + }); + await waitForCheckoutReady(); + + expect( + screen.queryByRole('button', { name: /razorpay/i }) + ).not.toBeInTheDocument(); + }); + + it('shows Razorpay when configured is true', async () => { + renderCheckout({ + sessionOverrides: { + paymentMethods: razorpayPaymentMethods, + paymentProviderConfiguration: { razorpay: { configured: true } }, + } as never, + }); + await waitForCheckoutReady(); + + expect( + screen.getByRole('button', { name: /razorpay/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx index 6a7d9b33..dfe24cdf 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-razorpay.test.tsx @@ -15,6 +15,9 @@ function buildRazorpaySession() { checkoutTypes: ['standard'], }, } as never; + session.paymentProviderConfiguration = { + razorpay: { configured: true }, + } as never; return session; } diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index aea2903e..b104594e 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -86,6 +86,10 @@ export type PayPalConfig = { disableFunding?: Array<'credit' | 'card' | 'paylater' | 'venmo'>; }; +export type RazorpayConfig = { + configured: boolean; +}; + export type MercadoPagoConfig = { publicKey: string; country: 'AR' | 'BR' | 'CO' | 'CL' | 'PE' | 'MX'; @@ -107,6 +111,7 @@ interface CheckoutContextValue { godaddyPaymentsConfig?: GodaddyPaymentsConfig; squareConfig?: SquareConfig; paypalConfig?: PayPalConfig; + razorpayConfig?: RazorpayConfig; mercadoPagoConfig?: MercadoPagoConfig; ccavenueConfig?: CCAvenueConfig; isConfirmingCheckout: boolean; @@ -280,6 +285,12 @@ export function Checkout(props: CheckoutProps) { } : undefined); + const sessionRazorpayConfig = session?.paymentProviderConfiguration?.razorpay; + const effectiveRazorpayConfig: RazorpayConfig | undefined = + sessionRazorpayConfig + ? { configured: sessionRazorpayConfig.configured } + : undefined; + const validationMessages = React.useMemo( () => ({ enterValidBillingPhone: t.validation.enterValidBillingPhone, @@ -357,6 +368,7 @@ export function Checkout(props: CheckoutProps) { squareConfig, mercadoPagoConfig, paypalConfig: effectivePayPalConfig, + razorpayConfig: effectiveRazorpayConfig, ccavenueConfig, requiredFields, isConfirmingCheckout, diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx index 67e114f6..de2c4ae6 100644 --- a/packages/react/src/components/checkout/payment/payment-form.tsx +++ b/packages/react/src/components/checkout/payment/payment-form.tsx @@ -109,6 +109,7 @@ export function PaymentForm( requiredFields, godaddyPaymentsConfig, paypalConfig, + razorpayConfig, } = useCheckoutContext(); const form = useFormContext(); const paymentMethod = form.watch('paymentMethod'); @@ -286,10 +287,9 @@ export function PaymentForm( // be null on a session created with explicit paymentMethods input // (only paymentProviderConfiguration gets resolved by discovery in // that case). Only treat a null paypal method as "standard" when real - // PayPal SDK config actually exists — not a blind pretend, gated on - // real data. (No equivalent exists for Razorpay: its public config is - // never delivered via paymentProviderConfiguration by design — see - // razorpay-resolver.ts — so there's nothing to check it against here.) + // PayPal SDK config actually exists. (Razorpay's equivalent signal, + // paymentProviderConfiguration.razorpay.configured, is checked in the + // stable gating block below instead.) const isPayPalWithRealConfig = key === PaymentMethodType.PAYPAL && !!paypalConfig?.clientId?.trim(); const effectiveCheckoutTypes = @@ -329,6 +329,15 @@ export function PaymentForm( return baseCheck && !!paypalConfig?.clientId?.trim(); } + // Razorpay requires checkout-api to have resolved a working merchant + // account before the button is offered. + if ( + key === PaymentMethodType.RAZORPAY && + method?.processor === PaymentProvider.RAZORPAY + ) { + return baseCheck && razorpayConfig?.configured === true; + } + // Special handling for GoDaddy wallet payments — only show when device supports them if ( key === PaymentMethodType.PAZE && @@ -360,6 +369,7 @@ export function PaymentForm( applePaySupported, googlePaySupported, paypalConfig?.clientId, + razorpayConfig?.configured, ]); const shouldShowBilling = diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index 7691a432..3f342723 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -3890,6 +3890,34 @@ const introspection = { }, "args": [], "isDeprecated": false + }, + { + "name": "razorpay", + "type": { + "kind": "OBJECT", + "name": "CheckoutSessionRazorpayProviderConfiguration" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "CheckoutSessionRazorpayProviderConfiguration", + "fields": [ + { + "name": "configured", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + "args": [], + "isDeprecated": false } ], "interfaces": [] diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index 39dbc9fb..27dc1952 100644 --- a/packages/react/src/lib/godaddy/checkout-mutations.ts +++ b/packages/react/src/lib/godaddy/checkout-mutations.ts @@ -145,6 +145,9 @@ export const CreateCheckoutSessionMutation = graphql(` partnerAttributionId disableFunding } + razorpay { + configured + } } draftOrder { id diff --git a/packages/react/src/lib/godaddy/checkout-queries.ts b/packages/react/src/lib/godaddy/checkout-queries.ts index 3c104fa0..961ef2ad 100644 --- a/packages/react/src/lib/godaddy/checkout-queries.ts +++ b/packages/react/src/lib/godaddy/checkout-queries.ts @@ -162,6 +162,9 @@ export const GetCheckoutSessionQuery = graphql(` partnerAttributionId disableFunding } + razorpay { + configured + } } locations { id From 8e345753503285b13d98500e14caf61374e57e82 Mon Sep 17 00:00:00 2001 From: Dhruv Gupta Date: Thu, 1 Oct 2026 15:40:28 +0530 Subject: [PATCH 11/12] Request NO_SHIPPING from PayPal for digital-only orders PayPalCheckoutButton only suppressed the shipping address for local pickup (application_context.shipping_preference and purchase_units[].shipping were keyed off isPickup alone). A digital-only order never collects a shipping address either, but use-build-payment-request.ts still builds a country-code-only shipping stub for it, which PayPal validates as a "provided" address and rejects with POSTAL_CODE_REQUIRED. Extend the existing suppression to digital-only orders via the same isDigitalOnlyOrder helper checkout-form.tsx already uses, instead of adding a parallel check. Co-Authored-By: Claude Sonnet 5 --- .../checkout-buttons/paypal/paypal.test.tsx | 49 ++++++++++++++++++- .../checkout-buttons/paypal/paypal.tsx | 15 +++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.test.tsx index 0759a8db..b853e87a 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.test.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.test.tsx @@ -25,6 +25,7 @@ import { type CheckoutFormData, checkoutContext, } from '@/components/checkout/checkout'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; import { DraftOrderSyncProvider } from '@/components/checkout/order/draft-order-sync-provider'; import { PayPalCheckoutButton } from '@/components/checkout/payment/checkout-buttons/paypal/paypal'; import { GoDaddyProvider } from '@/godaddy-provider'; @@ -66,9 +67,17 @@ const PAYPAL_ORDER_ID = 'paypal-order-1'; let form: UseFormReturn | undefined; -function renderPayPalButton({ enableTips = true, tipAmount = 0 } = {}) { +function renderPayPalButton({ + enableTips = true, + tipAmount = 0, + draftOrderOverrides, +}: { + enableTips?: boolean; + tipAmount?: number; + draftOrderOverrides?: Parameters[0]; +} = {}) { const session = buildCheckoutSession({ enableTips }); - const draftOrder = buildDraftOrder(); + const draftOrder = buildDraftOrder(draftOrderOverrides); mockGodaddyApi({ session, draftOrder }); const queryClient = createTestQueryClient(); @@ -255,4 +264,40 @@ describe('PayPalCheckoutButton', () => { expect(tipMinorUnitsInOrder(createdOrders[0])).toBe(0); expect(confirmInput()?.tipAmount).toBeUndefined(); }); + + it('requests NO_SHIPPING and omits shipping for a digital-only order', async () => { + // A digital-only order never collects a shipping address, so the stub + // (country-code-only) shipping object use-build-payment-request.ts builds + // must be dropped here, same as the pickup case already was — otherwise + // PayPal rejects it with POSTAL_CODE_REQUIRED for an address we never + // asked the buyer for. + renderPayPalButton({ + draftOrderOverrides: { + lineItems: [ + { + id: 'line-item-1', + name: 'Digital Game', + type: DeliveryMethods.DIGITAL, + fulfillmentMode: DeliveryMethods.DIGITAL, + }, + ], + }, + }); + const createdOrders: Array> = []; + + await act(async () => { + await getPayPalButtonsProps().createOrder?.( + {}, + payPalActions(createdOrders) + ); + }); + + const purchaseUnit = ( + createdOrders[0].purchase_units as Array> + )[0]; + expect(purchaseUnit.shipping).toBeUndefined(); + expect(createdOrders[0].application_context).toMatchObject({ + shipping_preference: 'NO_SHIPPING', + }); + }); }); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx index 6eaac838..f70b0331 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx @@ -14,6 +14,7 @@ import { } 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 { isDigitalOnlyOrder } from '@/components/checkout/utils/fulfillment'; import { Skeleton } from '@/components/ui/skeleton'; import { useGoDaddyContext } from '@/godaddy-provider'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; @@ -68,18 +69,28 @@ function PayPalButtonsWrapper() { const request = buildPaymentRequestsFromOrder( latestOrder ?? undefined ).payPalRequest; + // PayPal validates whatever `shipping.address` we send, even a mostly-empty + // one — the country-code-only stub use-build-payment-request.ts always + // builds (there's no real address to collect) fails with + // POSTAL_CODE_REQUIRED unless we omit `shipping` and ask for NO_SHIPPING. + // Pickup already did this; digital-only orders need the same treatment, + // since they never collect a shipping address either. + const suppressShipping = + isPickup || isDigitalOnlyOrder(latestOrder?.lineItems); const order = { ...request, purchase_units: request.purchase_units ? [ { ...request.purchase_units[0], - ...(isPickup ? { shipping: undefined } : {}), // Remove shipping if pickup + ...(suppressShipping ? { shipping: undefined } : {}), }, ] : undefined, application_context: { - shipping_preference: isPickup ? 'NO_SHIPPING' : 'SET_PROVIDED_ADDRESS', + shipping_preference: suppressShipping + ? 'NO_SHIPPING' + : 'SET_PROVIDED_ADDRESS', }, }; return await actions.order.create(order); From bea83a93acc97c6da67b378bbc1a10cef07af276 Mon Sep 17 00:00:00 2001 From: Dhruv Gupta Date: Wed, 7 Oct 2026 12:16:32 +0530 Subject: [PATCH 12/12] debug: force credit-card to resolve as PayPal Advanced Card Payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEMP FOR TESTING — DO NOT COMMIT: mutates session.paymentMethods.card.processor to 'paypal' right where session comes out of useCheckoutContext(), so both the availability filter (configuredPaymentMethods) and useGetSelectedPaymentMethod's own independent useCheckoutContext() read the override consistently, letting PayPalCreditCardForm/PayPalCreditCardCheckoutButton render regardless of what the session actually configured. Must be reverted. Co-Authored-By: Claude Sonnet 5 --- .../components/checkout/payment/payment-form.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx index de2c4ae6..06996c77 100644 --- a/packages/react/src/components/checkout/payment/payment-form.tsx +++ b/packages/react/src/components/checkout/payment/payment-form.tsx @@ -111,6 +111,19 @@ export function PaymentForm( paypalConfig, razorpayConfig, } = useCheckoutContext(); + // TEMP FOR TESTING — DO NOT COMMIT: force credit-card to resolve as + // PayPal's Advanced Card Payments instead of whatever the session actually + // configured (normally GoDaddy Payments), to test PayPalCreditCardForm / + // PayPalCreditCardCheckoutButton. Mutates the shared session object in + // place so both the availability filter below (configuredPaymentMethods) + // and useGetSelectedPaymentMethod's own independent useCheckoutContext() + // read the same override. + if (session?.paymentMethods?.card) { + session.paymentMethods.card = { + ...session.paymentMethods.card, + processor: PaymentProvider.PAYPAL, + }; + } const form = useFormContext(); const paymentMethod = form.watch('paymentMethod'); const deliveryMethod = form.watch('deliveryMethod');