diff --git a/src/components/ErrorPage/ErrorPage.tsx b/src/components/ErrorPage/ErrorPage.tsx index 1dc800db..594a440b 100644 --- a/src/components/ErrorPage/ErrorPage.tsx +++ b/src/components/ErrorPage/ErrorPage.tsx @@ -68,6 +68,22 @@ function getErrorMessage(err: unknown): string | undefined { return err; } + // Plain object (e.g. raw API error, Stripe error object) – avoid "[object Object]" + if (err !== null && typeof err === 'object') { + const errObj = err as Record; + if (typeof errObj.message === 'string') { + return errObj.message; + } + if (typeof errObj.detail === 'string') { + return errObj.detail; + } + try { + return JSON.stringify(errObj, null, 2); + } catch { + return '[Unknown error]'; + } + } + return undefined; } diff --git a/src/components/ErrorPage/tests/ErrorPage.test.tsx b/src/components/ErrorPage/tests/ErrorPage.test.tsx index 527f3607..32161689 100644 --- a/src/components/ErrorPage/tests/ErrorPage.test.tsx +++ b/src/components/ErrorPage/tests/ErrorPage.test.tsx @@ -127,4 +127,26 @@ describe('ErrorPage', () => { renderComponent(); validateText("We're sorry, something went wrong"); }); + + it('displays error detail from plain object route error', () => { + (useRouteError as jest.Mock).mockReturnValue({ detail: 'Detailed object error' }); + renderComponent(); + validateText('Detailed object error'); + }); + + it('displays JSON string when plain object has no message or detail', () => { + (useRouteError as jest.Mock).mockReturnValue({ code: 'E_SOMETHING' }); + renderComponent(); + validateText('"code": "E_SOMETHING"', { exact: false }); + }); + + it('falls back to provided message when route error processing throws', () => { + const fallbackMessage = 'Fallback prop message'; + (isRouteErrorResponse as unknown as jest.Mock).mockImplementation(() => { + throw new Error('route parsing failed'); + }); + (useRouteError as jest.Mock).mockReturnValue({ status: 500, statusText: 'Boom' }); + renderComponent({ message: fallbackMessage }); + validateText(fallbackMessage); + }); }); diff --git a/src/components/FormFields/BillingFormFields.tsx b/src/components/FormFields/BillingFormFields.tsx index f9d3fff7..4a5104df 100644 --- a/src/components/FormFields/BillingFormFields.tsx +++ b/src/components/FormFields/BillingFormFields.tsx @@ -1,8 +1,18 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Form } from '@openedx/paragon'; import { AddressElement, PaymentElement } from '@stripe/react-stripe-js'; -import { StripeAddressElementOptions } from '@stripe/stripe-js'; +import { StripeAddressElementChangeEvent, StripeAddressElementOptions } from '@stripe/stripe-js'; +import { useCallback } from 'react'; import { FieldContainer } from '@/components/FieldContainer'; +import { DataStoreKey } from '@/constants/checkout'; +import { useCheckoutFormStore } from '@/hooks/useCheckoutFormStore'; + +import type { UseFormReturn } from 'react-hook-form'; + +interface BillingFormFieldsProps { + form: UseFormReturn; +} const BillingAddressTitle = () => (

@@ -33,31 +43,72 @@ const BillingPaymentTitle = () => ( ); -const BillingFormFields = () => { - /** - * Stripe AddressElement configured for collecting the cardholder’s billing address. - * - * The `mode: "billing"` option ensures the address is tied to the payment method - * and used for fraud checks and payment authorization. - * - * Docs: https://docs.stripe.com/elements/address-element - */ +/** + * BillingFormFields component + * + * Renders the billing address form and payment element for the billing details page. + * Uses Stripe AddressElement and PaymentElement. + * AddressElement provides autocomplete/manual entry and returns + * normalized address fields (line1, line2, city, state, postal code). + */ +const BillingFormFields = ({ form }: BillingFormFieldsProps) => { + const billingDetailsData = useCheckoutFormStore( + (state) => state.formData[DataStoreKey.BillingDetails], + ); + const setFormData = useCheckoutFormStore((state) => state.setFormData); + + const onAddressChange = useCallback((event: StripeAddressElementChangeEvent) => { + const name = event.value?.name || ''; + const country = event.value?.address?.country || ''; + const line1 = event.value?.address?.line1 || ''; + const line2 = event.value?.address?.line2 || ''; + const city = event.value?.address?.city || ''; + const state = event.value?.address?.state || ''; + const zip = event.value?.address?.postal_code || ''; + + form.setValue('fullName', name, { shouldValidate: true, shouldDirty: true, shouldTouch: true }); + form.setValue('country', country, { shouldValidate: true, shouldDirty: true, shouldTouch: true }); + form.setValue('line1', line1, { shouldValidate: true, shouldDirty: true, shouldTouch: true }); + form.setValue('line2', line2, { shouldValidate: true, shouldDirty: true, shouldTouch: true }); + form.setValue('city', city, { shouldValidate: true, shouldDirty: true, shouldTouch: true }); + form.setValue('state', state, { shouldValidate: true, shouldDirty: true, shouldTouch: true }); + form.setValue('zip', zip, { shouldValidate: true, shouldDirty: true, shouldTouch: true }); + + setFormData(DataStoreKey.BillingDetails, { + ...billingDetailsData, + fullName: name, + country, + line1, + line2, + city, + state, + zip, + }); + }, [billingDetailsData, form, setFormData]); + const addressElementOptions: StripeAddressElementOptions = { mode: 'billing', }; + return ( <> + {form.formState.errors?.fullName?.message && ( + + {form.formState.errors.fullName.message} + + )} diff --git a/src/components/FormFields/tests/BillingFormFields.test.tsx b/src/components/FormFields/tests/BillingFormFields.test.tsx new file mode 100644 index 00000000..04b93a89 --- /dev/null +++ b/src/components/FormFields/tests/BillingFormFields.test.tsx @@ -0,0 +1,286 @@ +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; + +import { DataStoreKey } from '@/constants/checkout'; +import { useCheckoutFormStore } from '@/hooks/useCheckoutFormStore'; + +import BillingFormFields from '../BillingFormFields'; + +const mockSetFormData = jest.fn(); + +const mockAddressChangeEvent = { + value: { + name: 'John Doe', + address: { + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'Boston', + state: 'MA', + postal_code: '02109', + }, + }, +}; + +jest.mock('@/hooks/useCheckoutFormStore', () => ({ + useCheckoutFormStore: jest.fn(), +})); + +jest.mock('@stripe/react-stripe-js', () => ({ + AddressElement: ({ onChange }: { onChange: (event: any) => void }) => ( + + ), + PaymentElement: () =>
PaymentElement
, +})); + +describe('BillingFormFields', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useCheckoutFormStore).mockImplementation((selector) => selector({ + formData: { + [DataStoreKey.BillingDetails]: {}, + }, + setFormData: mockSetFormData, + checkoutSessionClientSecret: undefined, + checkoutSessionStatus: { + type: null, + paymentStatus: null, + }, + setCheckoutSessionClientSecret: jest.fn(), + setCheckoutSessionStatus: jest.fn(), + })); + }); + + it('renders Stripe address and payment elements', () => { + const Wrapper = () => { + const form = useForm({ + mode: 'onTouched', + defaultValues: { + fullName: '', + country: '', + line1: '', + line2: '', + city: '', + state: '', + zip: '', + }, + }); + + return ( + + + + ); + }; + + render(); + + expect(screen.getByTestId('address-element')).toBeInTheDocument(); + expect(screen.getByTestId('payment-element')).toBeInTheDocument(); + }); + + it('maps Stripe AddressElement fields into store on change', async () => { + const user = userEvent.setup(); + + const Wrapper = () => { + const form = useForm({ + mode: 'onTouched', + defaultValues: { + fullName: '', + country: '', + line1: '', + line2: '', + city: '', + state: '', + zip: '', + }, + }); + + return ( + + + + ); + }; + + render(); + + await user.click(screen.getByTestId('address-element')); + + expect(mockSetFormData).toHaveBeenCalledWith( + DataStoreKey.BillingDetails, + expect.objectContaining({ + fullName: 'John Doe', + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'Boston', + state: 'MA', + zip: '02109', + }), + ); + }); + + it('renders fullName validation feedback when form error exists', () => { + const Wrapper = () => { + const form = useForm({ + mode: 'onTouched', + defaultValues: { + fullName: '', + country: '', + line1: '', + line2: '', + city: '', + state: '', + zip: '', + }, + }); + + useEffect(() => { + form.setError('fullName', { + type: 'manual', + message: 'Please provide your full name.', + }); + }, [form]); + + return ( + + + + ); + }; + + render(); + + expect(screen.getByText('Please provide your full name.')).toBeInTheDocument(); + }); + + it('handles address change event without errors', async () => { + const user = userEvent.setup(); + + const Wrapper = () => { + const form = useForm({ + mode: 'onTouched', + defaultValues: { + fullName: '', + country: '', + line1: '', + line2: '', + city: '', + state: '', + zip: '', + }, + }); + + return ( + + + + ); + }; + + render(); + await user.click(screen.getByTestId('address-element')); + expect(mockSetFormData).toHaveBeenCalled(); + }); + + it('updates form setValue for all address fields when address changes', async () => { + const user = userEvent.setup(); + const setValueSpy = jest.fn(); + + const Wrapper = () => { + const form = useForm({ + mode: 'onTouched', + defaultValues: { + fullName: '', + country: '', + line1: '', + line2: '', + city: '', + state: '', + zip: '', + }, + }); + + // Spy on the setValue method + const originalSetValue = form.setValue; + form.setValue = jest.fn(originalSetValue); + setValueSpy.mockImplementation(form.setValue); + + return ( + + + + ); + }; + + render(); + await user.click(screen.getByTestId('address-element')); + + expect(mockSetFormData).toHaveBeenCalled(); + }); + + it('renders billing section titles and descriptions', () => { + const Wrapper = () => { + const form = useForm({ + mode: 'onTouched', + defaultValues: { + fullName: '', + country: '', + line1: '', + line2: '', + city: '', + state: '', + zip: '', + }, + }); + + return ( + + + + ); + }; + + render(); + + // Check that the billing section titles are rendered via translated messages + // Note: These will be translated via FormattedMessage, so we check for the elements + expect(screen.getByTestId('address-element')).toBeInTheDocument(); + expect(screen.getByTestId('payment-element')).toBeInTheDocument(); + }); + + it('does not render fullName error feedback when no error exists', () => { + const Wrapper = () => { + const form = useForm({ + mode: 'onTouched', + defaultValues: { + fullName: 'Test User', + country: 'US', + line1: '123 Main', + line2: '', + city: 'Boston', + state: 'MA', + zip: '02109', + }, + }); + + return ( + + + + ); + }; + + render(); + + // Should not have error feedback displayed + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/StatefulButton/StatefulSubscribeButton.tsx b/src/components/StatefulButton/StatefulSubscribeButton.tsx index 70794fd0..a2c2a245 100644 --- a/src/components/StatefulButton/StatefulSubscribeButton.tsx +++ b/src/components/StatefulButton/StatefulSubscribeButton.tsx @@ -5,6 +5,7 @@ import { StatefulButton } from '@openedx/paragon'; import { CheckoutContextValue, useCheckout } from '@stripe/react-stripe-js'; import { StripeCheckoutStatus } from '@stripe/stripe-js'; import { useQueryClient } from '@tanstack/react-query'; +import PropTypes from 'prop-types'; import { useContext, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -54,7 +55,11 @@ const buttonMessages = defineMessages({ }, }); -const StatefulSubscribeButton = () => { +interface StatefulSubscribeButtonProps { + onClick?: () => void; +} + +const StatefulSubscribeButton: React.FC = ({ onClick }) => { const [statefulButtonState, setStatefulButtonState] = useState('default'); const [errorMessageKey, setErrorMessageKey] = useState('fallback'); const { data: checkoutIntent } = useCheckoutIntent(); @@ -75,39 +80,80 @@ const StatefulSubscribeButton = () => { const billingDetailsData = useCheckoutFormStore((state) => state.formData[DataStoreKey.BillingDetails]); const setCheckoutSessionStatus = useCheckoutFormStore((state) => state.setCheckoutSessionStatus); - const hasInvalidTerms = Object.values(billingDetailsData).some((value) => !value); - const isFormValid = canConfirm && !hasInvalidTerms; + const requiredBillingFields: Array = [ + 'fullName', + 'country', + 'line1', + 'city', + 'state', + 'zip', + 'confirmTnC', + 'confirmSubscription', + 'confirmRecurringSubscription', + ]; + const hasMissingRequiredField = requiredBillingFields.some((field) => !billingDetailsData?.[field]); + const isFormValid = canConfirm && !hasMissingRequiredField; const onClickHandler = async () => { + // Call the parent's onClick handler first (e.g., for tracking) + onClick?.(); + // Sets the button to pending state and then calls confirm() setStatefulButtonState('pending'); - // Calls confirm() to start the Stripe checkout flow. - let response; - try { - if (checkoutIntent) { - const { uuid, country, state } = checkoutIntent; - const tncCheckoutUpdateRequest: CheckoutIntentPatchRequestSchema = { - country, - state, - termsMetadata: termsAndConditions, - }; + // Step 1: Persist T&C acceptance on the checkout intent before charging. + // This is a separate try-catch so patchCheckoutIntent failures are clearly + // distinguished from Stripe confirm() failures and never silently swallowed. + if (checkoutIntent) { + const { uuid, country, state } = checkoutIntent; + const tncCheckoutUpdateRequest: CheckoutIntentPatchRequestSchema = { + country, + state, + termsMetadata: termsAndConditions, + }; + try { await patchCheckoutIntent({ uuid, requestData: tncCheckoutUpdateRequest, }); + } catch (patchError) { + const detail = patchError instanceof Error + ? patchError.message + : JSON.stringify(patchError); + logError( + `[BillingDetails] Failed to record terms acceptance before Stripe confirm – checkoutIntent: ${JSON.stringify(checkoutIntent)}, error: ${detail}`, + ); + setStatefulButtonState('error'); + setErrorMessageKey('fallback'); + return; } + } + + // Step 2: Confirm Stripe checkout. confirm() returns a StripeCheckoutStatus; + // it should not throw, but we guard against unexpected rejections anyway. + let response; + try { response = await confirm({ redirect: 'if_required', returnUrl: `${window.location.href}/${CheckoutSubstepKey.Success}`, }); - } catch (error) { - response = error; + } catch (confirmError) { + // confirm() threw instead of returning a status — convert to a safe error state. + const detail = confirmError instanceof Error + ? confirmError.message + : JSON.stringify(confirmError); + logError( + `[BillingDetails] Stripe confirm() threw unexpectedly – checkoutIntent: ${JSON.stringify(checkoutIntent)}, error: ${detail}`, + ); + setStatefulButtonState('error'); + setErrorMessageKey('fallback'); + return; } - // Set the button to the appropriate state based on the response. + + // Step 3: Map the Stripe response type to a button state. // Stripe responses map 1:1 to button states except for 'default' which is the initial state. - setStatefulButtonState(response.type || 'default'); - if (response.type === 'error') { + setStatefulButtonState(response?.type ?? 'error'); + if (response?.type === 'error') { setErrorMessageKey(buttonMessages.error[response.error?.code] ? response.error?.code : 'fallback'); logError( `[BillingDetails] Error during self service purchasing Stripe checkout for checkoutIntent: ${JSON.stringify(checkoutIntent)}, ${JSON.stringify(response.error)}`, @@ -169,4 +215,12 @@ const StatefulSubscribeButton = () => { ); }; +StatefulSubscribeButton.propTypes = { + onClick: PropTypes.func, +}; + +StatefulSubscribeButton.defaultProps = { + onClick: undefined, +}; + export default StatefulSubscribeButton; diff --git a/src/components/StatefulButton/tests/StatefulSubscribeButton.test.tsx b/src/components/StatefulButton/tests/StatefulSubscribeButton.test.tsx index 43625087..7ec8ea67 100644 --- a/src/components/StatefulButton/tests/StatefulSubscribeButton.test.tsx +++ b/src/components/StatefulButton/tests/StatefulSubscribeButton.test.tsx @@ -86,8 +86,16 @@ function setup(overrides = {}) { useCheckoutFormStore: { formData: { BillingDetails: { + fullName: 'John Doe', + country: 'US', + line1: '123 Main St', + line2: '', + city: 'Boston', + state: 'MA', + zip: '02109', confirmTnC: true, confirmSubscription: true, + confirmRecurringSubscription: true, }, }, setFormData: jest.fn(), diff --git a/src/components/Stepper/StepperContent/BillingDetailsContent.tsx b/src/components/Stepper/StepperContent/BillingDetailsContent.tsx index 34bdf221..4f7db613 100644 --- a/src/components/Stepper/StepperContent/BillingDetailsContent.tsx +++ b/src/components/Stepper/StepperContent/BillingDetailsContent.tsx @@ -12,7 +12,7 @@ interface BillingDetailsContentProps { const BillingDetailsContent = ({ form }: BillingDetailsContentProps) => ( - + diff --git a/src/components/StripeProvider/StripeProvider.tsx b/src/components/StripeProvider/StripeProvider.tsx index dfd93842..8a6f2bce 100644 --- a/src/components/StripeProvider/StripeProvider.tsx +++ b/src/components/StripeProvider/StripeProvider.tsx @@ -20,12 +20,31 @@ const StripeProvider = ({ children }: StripeProviderProps) => { return null; } + // Wrap fetchClientSecret so that any non-Error rejection from Stripe's SDK + // (e.g. a raw API error object like { type: 'invalid_request_error', ... }) + // is converted to a proper Error instance. Without this conversion the + // webpack dev overlay shows the unhelpful "[object Object]" message and + // React Router's error boundary cannot display a meaningful message either. + const fetchClientSecret = (): Promise => ( + Promise.resolve(checkoutSessionClientSecret).catch((err: unknown) => { + let message: string; + if (err instanceof Error) { + message = err.message; + } else if (typeof err === 'string') { + message = err; + } else { + message = JSON.stringify(err); + } + throw new Error(`Stripe session initialization failed: ${message}`); + }) + ); + return ( Promise.resolve(checkoutSessionClientSecret), + fetchClientSecret, elementsOptions: { appearance }, }} > diff --git a/src/components/StripeProvider/tests/StripeProvider.test.tsx b/src/components/StripeProvider/tests/StripeProvider.test.tsx new file mode 100644 index 00000000..8c827e11 --- /dev/null +++ b/src/components/StripeProvider/tests/StripeProvider.test.tsx @@ -0,0 +1,117 @@ +import { getConfig } from '@edx/frontend-platform/config'; +import { CheckoutProvider } from '@stripe/react-stripe-js'; +import { render, screen } from '@testing-library/react'; + +import { useCheckoutSessionClientSecret } from '@/components/app/data'; + +import StripeProvider from '../StripeProvider'; + +jest.mock('@edx/frontend-platform/config', () => ({ + getConfig: jest.fn(), +})); + +jest.mock('@stripe/stripe-js', () => ({ + loadStripe: jest.fn(() => Promise.resolve({})), +})); + +jest.mock('@stripe/react-stripe-js', () => ({ + CheckoutProvider: jest.fn(({ children }) =>
{children}
), +})); + +jest.mock('@/components/app/data', () => ({ + useCheckoutSessionClientSecret: jest.fn(), +})); + +jest.mock('@/components/StripeProvider/utils', () => ({ + createStripeAppearance: jest.fn(() => ({ theme: 'flat' })), +})); + +describe('StripeProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getConfig as jest.Mock).mockReturnValue({ PUBLISHABLE_STRIPE_API_KEY: 'pk_test_123' }); + }); + + it('returns null when checkout session client secret is missing', () => { + (useCheckoutSessionClientSecret as jest.Mock).mockReturnValue(undefined); + + const { container } = render( + +
Child content
+
, + ); + + expect(container.firstChild).toBeNull(); + expect(CheckoutProvider).not.toHaveBeenCalled(); + }); + + it('renders children and resolves fetchClientSecret when secret is provided', async () => { + (useCheckoutSessionClientSecret as jest.Mock).mockReturnValue('secret_123'); + + render( + +
Child content
+
, + ); + + expect(screen.getByText('Child content')).toBeInTheDocument(); + expect(CheckoutProvider).toHaveBeenCalled(); + + const checkoutProviderProps = (CheckoutProvider as jest.Mock).mock.calls[0][0]; + await expect(checkoutProviderProps.options.fetchClientSecret()).resolves.toBe('secret_123'); + }); + + it('wraps rejected Error values with a Stripe initialization message', async () => { + const rejectedThenable = { + then: (_resolve: unknown, reject: (reason: Error) => void) => reject(new Error('boom')), + }; + (useCheckoutSessionClientSecret as jest.Mock).mockReturnValue(rejectedThenable); + + render( + +
Child content
+
, + ); + + const checkoutProviderProps = (CheckoutProvider as jest.Mock).mock.calls[0][0]; + await expect(checkoutProviderProps.options.fetchClientSecret()).rejects.toThrow( + 'Stripe session initialization failed: boom', + ); + }); + + it('wraps rejected string values with a Stripe initialization message', async () => { + const rejectedThenable = { + then: (_resolve: unknown, reject: (reason: string) => void) => reject('raw string error'), + }; + (useCheckoutSessionClientSecret as jest.Mock).mockReturnValue(rejectedThenable); + + render( + +
Child content
+
, + ); + + const checkoutProviderProps = (CheckoutProvider as jest.Mock).mock.calls[0][0]; + await expect(checkoutProviderProps.options.fetchClientSecret()).rejects.toThrow( + 'Stripe session initialization failed: raw string error', + ); + }); + + it('wraps rejected object values by JSON stringifying them', async () => { + const rejectedThenable = { + then: (_resolve: unknown, reject: (reason: object) => void) => reject({ type: 'invalid_request_error' }), + }; + (useCheckoutSessionClientSecret as jest.Mock).mockReturnValue(rejectedThenable); + + render( + +
Child content
+
, + ); + + const checkoutProviderProps = (CheckoutProvider as jest.Mock).mock.calls[0][0]; + await expect(checkoutProviderProps.options.fetchClientSecret()).rejects.toThrow( + 'Stripe session initialization failed: {"type":"invalid_request_error"}', + ); + }); +}); diff --git a/src/components/app/routes/loaders/checkoutStepperLoader.ts b/src/components/app/routes/loaders/checkoutStepperLoader.ts index f87270f5..c1cd9406 100644 --- a/src/components/app/routes/loaders/checkoutStepperLoader.ts +++ b/src/components/app/routes/loaders/checkoutStepperLoader.ts @@ -128,8 +128,16 @@ async function billingDetailsLoader(queryClient: QueryClient): Promise { const navigate = useNavigate(); - const billingDetailsData = useCheckoutFormStore((state) => state.formData[DataStoreKey.BillingDetails]); + + const billingDetailsData = useCheckoutFormStore( + (state) => state.formData[DataStoreKey.BillingDetails], + ); + const setFormData = useCheckoutFormStore((state) => state.setFormData); const StepperContent = useStepperContent(); + const { data: formValidationConstraints } = useFormValidationConstraints(); const { data: checkoutIntent } = useCheckoutIntent(); @@ -37,38 +52,61 @@ const BillingDetailsPage: React.FC = () => { formSchema, } = useCurrentPageDetails(); - const billingDetailsSchema = useMemo(() => ( - formSchema(formValidationConstraints) - ), [formSchema, formValidationConstraints]); + const billingDetailsSchema = useMemo( + () => formSchema(formValidationConstraints), + [formSchema, formValidationConstraints], + ); const form = useForm({ mode: 'onTouched', resolver: zodResolver(billingDetailsSchema), - defaultValues: billingDetailsData, + defaultValues: { + fullName: '', + country: '', + line1: '', + line2: '', + city: '', + state: '', + zip: '', + ...(billingDetailsData || {}), + }, }); + const { handleSubmit, } = form; const onSubmit = async (data: BillingDetailsData) => { + setFormData(DataStoreKey.BillingDetails, data); + }; + + const handleSubscribeClick = () => { sendEnterpriseCheckoutTrackingEvent({ checkoutIntentId: checkoutIntent?.id ?? null, - eventName: EVENT_NAMES.SUBSCRIPTION_CHECKOUT.BILLING_DETAILS_SUBSCRIBE_BUTTON_CLICKED, + eventName: + EVENT_NAMES.SUBSCRIPTION_CHECKOUT + .BILLING_DETAILS_SUBSCRIBE_BUTTON_CLICKED, }); - setFormData(DataStoreKey.BillingDetails, data); + handleSubmit(onSubmit)().catch(() => { + // Form submission errors are handled by react-hook-form + }); }; const eventKey = CheckoutStepKey.BillingDetails; + return (
+ + {/* Stripe Address + Full Name fields rendered dynamically */} + {stepperActionButtonMessage && ( + - + + )} ); }; + export default BillingDetailsPage; diff --git a/src/components/billing-details-pages/tests/BillingDetailsPage.test.tsx b/src/components/billing-details-pages/tests/BillingDetailsPage.test.tsx index 0134555a..1c097beb 100644 --- a/src/components/billing-details-pages/tests/BillingDetailsPage.test.tsx +++ b/src/components/billing-details-pages/tests/BillingDetailsPage.test.tsx @@ -97,25 +97,65 @@ describe('BillingDetailsPage', () => { validateText('I confirm I am subscribing', { exact: false }); }); + it('renders the same billing address collection UI on essentials billing route', async () => { + renderStepperRoute('/essentials/billing-details', { + config: {}, + authenticatedUser: { + userId: 12345, + }, + } as any); + + expect(screen.getByTestId('stepper-title')).toHaveTextContent('Billing Details'); + validateText('AddressElement'); + validateText('PaymentElement'); + await waitFor(() => validateText('Subscribe')); + }); + it('emits tracking event when subscribe button is clicked', async () => { const user = userEvent.setup(); renderStepperRoute(CheckoutPageRoute.BillingDetails); - // Fill out the required terms and conditions checkboxes + // Initialize form store with required billing address fields + checkoutFormStore.setState((state) => ({ + ...state, + formData: { + ...state.formData, + [DataStoreKey.BillingDetails]: { + fullName: 'John Doe', + country: 'US', + line1: '123 Main St', + line2: '', + city: 'Boston', + state: 'MA', + zip: '02109', + confirmTnC: false, + confirmSubscription: false, + confirmRecurringSubscription: false, + }, + }, + })); + + // Click required checkboxes const tncCheckbox = screen.getByLabelText(/I have read and accepted/i); const subscriptionCheckbox = screen.getByLabelText(/I confirm I am subscribing/i); + const recurringCheckbox = screen.getByLabelText(/I agree to enroll in a recurring/i); await user.click(tncCheckbox); await user.click(subscriptionCheckbox); + await user.click(recurringCheckbox); + + jest.clearAllMocks(); const subscribeButton = screen.getByRole('button', { name: 'Subscribe' }); await user.click(subscribeButton); - expect(sendEnterpriseCheckoutTrackingEvent).toHaveBeenCalledWith({ - checkoutIntentId: 'test-checkout-intent-id', - eventName: EVENT_NAMES.SUBSCRIPTION_CHECKOUT.BILLING_DETAILS_SUBSCRIBE_BUTTON_CLICKED, - }); + expect(sendEnterpriseCheckoutTrackingEvent).toHaveBeenCalledWith( + expect.objectContaining({ + checkoutIntentId: 'test-checkout-intent-id', + eventName: EVENT_NAMES.SUBSCRIPTION_CHECKOUT.BILLING_DETAILS_SUBSCRIBE_BUTTON_CLICKED, + }), + ); }); }); @@ -150,7 +190,6 @@ describe('BillingDetailsSuccessPage', () => { }, refetch: jest.fn().mockImplementation(() => ({ catch: jest.fn() })), }); - // Ensure OrderDetails renders in success page by providing a valid invoice (useFirstBillableInvoice as jest.Mock).mockReturnValue({ data: { last4: '4242', @@ -163,7 +202,6 @@ describe('BillingDetailsSuccessPage', () => { }); it('renders the title correctly based on form state (first name from Plan Details)', () => { - // Seed the form store with a full name as entered/derived in Plan Details checkoutFormStore.setState((s) => ({ ...s, formData: { @@ -182,8 +220,6 @@ describe('BillingDetailsSuccessPage', () => { }, }); - // The Billing Details Success title uses the "firstName" param populated from the form state. - // The component currently passes fullName to the "firstName" placeholder. expect(screen.getByTestId('stepper-title')).toHaveTextContent('Thank you, Alice Example.'); }); @@ -195,7 +231,7 @@ describe('BillingDetailsSuccessPage', () => { }, }); validateText('Order details'); - validateText('You have purchased an edX team\'s subscription.'); + validateText("You have purchased an edX team's subscription."); }); it('renders the SuccessHeading component', async () => { diff --git a/src/constants/checkout.ts b/src/constants/checkout.ts index 88101068..bbbffeec 100644 --- a/src/constants/checkout.ts +++ b/src/constants/checkout.ts @@ -270,9 +270,40 @@ export const AccountDetailsSchema = ( // eslint-disable-next-line @typescript-eslint/no-unused-vars export const BillingDetailsSchema = (constraints: CheckoutContextFieldConstraints) => ( z.object({ + fullName: z.string().trim() + .min( + constraints?.fullName?.minLength ?? 1, + 'Please provide your full name.', + ) + .max( + constraints?.fullName?.maxLength ?? 150, + `Name is too long. It must contain no more than ${constraints?.fullName?.maxLength ?? 150} characters.`, + ), + + country: z.string().trim() + .min( + constraints?.country?.minLength ?? 2, + 'Country is required', + ), + + line1: z.string().trim() + .min(1, 'Address line 1 is required'), + + line2: z.string().trim().optional(), + + city: z.string().trim() + .min(1, 'City is required'), + + state: z.string().trim() + .min(1, 'State is required'), + + zip: z.string().trim() + .min(1, 'ZIP code is required'), + confirmTnC: z.boolean().refine((value) => value, { message: 'Please accept the terms.', }), + confirmSubscription: z.boolean().refine((value) => value, { message: 'Please confirm organization subscription.', }),