From 7073fa068de7360019d8e8346b62ed520c43ccff Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Wed, 8 Oct 2025 22:56:01 -0400 Subject: [PATCH 1/4] fix: poll checkout intent API within billing details --- .../StatefulSubscribeButton.tsx | 23 ++++++++-------- .../tests/StatefulSubscribeButton.test.tsx | 2 +- src/components/app/data/services/context.ts | 1 + .../routes/loaders/checkoutStepperLoader.ts | 27 ++++++++++++++++--- .../tests/BillingDetailsPage.test.tsx | 6 +++++ 5 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/components/StatefulButton/StatefulSubscribeButton.tsx b/src/components/StatefulButton/StatefulSubscribeButton.tsx index dfb859b9..ddb92fdf 100644 --- a/src/components/StatefulButton/StatefulSubscribeButton.tsx +++ b/src/components/StatefulButton/StatefulSubscribeButton.tsx @@ -8,8 +8,8 @@ import { useQueryClient } from '@tanstack/react-query'; import { useContext, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useCheckoutIntent } from '@/components/app/data'; -import { queryBffContext } from '@/components/app/data/queries/queries'; +import { useCheckoutIntent, usePolledCheckoutIntent } from '@/components/app/data'; +import { determineExistingSuccessfulCheckoutIntent } from '@/components/app/data/services/context'; import { CheckoutPageRoute, CheckoutSubstepKey, DataStoreKey } from '@/constants/checkout'; import EVENT_NAMES from '@/constants/events'; import { useCheckoutFormStore } from '@/hooks/useCheckoutFormStore'; @@ -57,7 +57,7 @@ const StatefulSubscribeButton = () => { const [errorMessageKey, setErrorMessageKey] = useState('fallback'); const { data: checkoutIntent } = useCheckoutIntent(); const intl = useIntl(); - + const { data: polledCheckoutIntent } = usePolledCheckoutIntent(); const queryClient = useQueryClient(); const navigate = useNavigate(); const { authenticatedUser }: AppContextValue = useContext(AppContext); @@ -92,7 +92,6 @@ const StatefulSubscribeButton = () => { } // Set the button to the appropriate state based on the response. // Stripe responses map 1:1 to button states except for 'default' which is the initial state. - setStatefulButtonState(response.type || 'default'); if (response.type === 'error') { setErrorMessageKey(buttonMessages.error[response.error?.code] ? response.error?.code : 'fallback'); logError( @@ -101,18 +100,18 @@ const StatefulSubscribeButton = () => { } }; + // Refetch the checkout intent if the polled intent state changes. + useEffect(() => { + if (statefulButtonState === 'pending' && determineExistingSuccessfulCheckoutIntent(polledCheckoutIntent?.state)) { + setStatefulButtonState('success'); + } + }, [polledCheckoutIntent?.state, navigate, statefulButtonState]); + useEffect(() => { if (statefulButtonState === 'success') { - // If the payment succeeded, update the checkout session status. + // If the payment succeeded from the stripe API, update the checkout session status. if (status.type === 'complete' && status.paymentStatus === 'paid') { setCheckoutSessionStatus(status); - queryClient.invalidateQueries({ - queryKey: queryBffContext( - authenticatedUser.id, - ).queryKey, - }) - .then(data => data) - .catch(error => logError(error)); sendEnterpriseCheckoutTrackingEvent({ checkoutIntentId: checkoutIntent?.id ?? null, eventName: EVENT_NAMES.SUBSCRIPTION_CHECKOUT.PAYMENT_PROCESSED_SUCCESSFULLY, diff --git a/src/components/StatefulButton/tests/StatefulSubscribeButton.test.tsx b/src/components/StatefulButton/tests/StatefulSubscribeButton.test.tsx index 9397f9da..616331ee 100644 --- a/src/components/StatefulButton/tests/StatefulSubscribeButton.test.tsx +++ b/src/components/StatefulButton/tests/StatefulSubscribeButton.test.tsx @@ -42,6 +42,7 @@ jest.mock('react-router-dom', () => ({ jest.mock('@/components/app/data', () => ({ useCheckoutIntent: jest.fn(() => ({ data: { id: 'test-intent' } })), + usePolledCheckoutIntent: jest.fn(() => ({ data: { state: 'paid' } })), })); jest.mock('@/hooks/useCheckoutFormStore', () => ({ @@ -333,7 +334,6 @@ describe('StatefulSubscribeButton', () => { // Wait for the useEffect to trigger after state changes to 'success' await waitFor(() => { expect(mockSetCheckoutSessionStatus).toHaveBeenCalledWith({ type: 'complete', paymentStatus: 'paid' }); - expect(mockInvalidateQueries).toHaveBeenCalled(); expect(jest.mocked(sendEnterpriseCheckoutTrackingEvent)).toHaveBeenCalledWith({ checkoutIntentId: 'test-intent', eventName: EVENT_NAMES.SUBSCRIPTION_CHECKOUT.PAYMENT_PROCESSED_SUCCESSFULLY, diff --git a/src/components/app/data/services/context.ts b/src/components/app/data/services/context.ts index fef4d2c9..e64ab592 100644 --- a/src/components/app/data/services/context.ts +++ b/src/components/app/data/services/context.ts @@ -80,5 +80,6 @@ const fetchCheckoutSuccess = async (): Promise => { export { fetchCheckoutContext, fetchCheckoutSuccess, + determineExistingSuccessfulCheckoutIntent, paymentProcessedCheckoutIntentStates, }; diff --git a/src/components/app/routes/loaders/checkoutStepperLoader.ts b/src/components/app/routes/loaders/checkoutStepperLoader.ts index baa323c2..ead37095 100644 --- a/src/components/app/routes/loaders/checkoutStepperLoader.ts +++ b/src/components/app/routes/loaders/checkoutStepperLoader.ts @@ -2,7 +2,8 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { QueryClient } from '@tanstack/react-query'; import { redirect } from 'react-router-dom'; -import { queryBffContext } from '@/components/app/data/queries/queries'; +import { queryBffContext, queryBffSuccess, queryCheckoutIntent } from '@/components/app/data/queries/queries'; +import { determineExistingSuccessfulCheckoutIntent } from '@/components/app/data/services/context'; import { getCheckoutSessionClientSecret, validateFormState } from '@/components/app/routes/loaders/utils'; import { CheckoutPageRoute } from '@/constants/checkout'; import { checkoutFormStore } from '@/hooks/useCheckoutFormStore'; @@ -144,12 +145,32 @@ async function billingDetailsSuccessLoader(queryClient: QueryClient): Promise { }, }); (useCheckoutSessionClientSecret as jest.Mock).mockReturnValue('secret-123'); + (usePolledCheckoutIntent as jest.Mock).mockReturnValue({ + data: { + id: 1, + state: 'pending', + }, + }); (useCheckout as jest.Mock).mockReturnValue({ canConfirm: true, confirm: jest.fn().mockResolvedValue({ type: 'success' }), From fd7807f563623d96cd933a51e77e2d7287ec2e7a Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Thu, 9 Oct 2025 13:09:33 -0400 Subject: [PATCH 2/4] chore: PR feedback --- src/components/StatefulButton/StatefulSubscribeButton.tsx | 2 +- src/components/app/routes/loaders/checkoutStepperLoader.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/StatefulButton/StatefulSubscribeButton.tsx b/src/components/StatefulButton/StatefulSubscribeButton.tsx index ddb92fdf..de1d19ec 100644 --- a/src/components/StatefulButton/StatefulSubscribeButton.tsx +++ b/src/components/StatefulButton/StatefulSubscribeButton.tsx @@ -100,7 +100,7 @@ const StatefulSubscribeButton = () => { } }; - // Refetch the checkout intent if the polled intent state changes. + // Visually alter the Subscribe button to a "successful" appearance if the polled intent state becomes successful. useEffect(() => { if (statefulButtonState === 'pending' && determineExistingSuccessfulCheckoutIntent(polledCheckoutIntent?.state)) { setStatefulButtonState('success'); diff --git a/src/components/app/routes/loaders/checkoutStepperLoader.ts b/src/components/app/routes/loaders/checkoutStepperLoader.ts index ead37095..dc8d7a52 100644 --- a/src/components/app/routes/loaders/checkoutStepperLoader.ts +++ b/src/components/app/routes/loaders/checkoutStepperLoader.ts @@ -3,7 +3,6 @@ import { QueryClient } from '@tanstack/react-query'; import { redirect } from 'react-router-dom'; import { queryBffContext, queryBffSuccess, queryCheckoutIntent } from '@/components/app/data/queries/queries'; -import { determineExistingSuccessfulCheckoutIntent } from '@/components/app/data/services/context'; import { getCheckoutSessionClientSecret, validateFormState } from '@/components/app/routes/loaders/utils'; import { CheckoutPageRoute } from '@/constants/checkout'; import { checkoutFormStore } from '@/hooks/useCheckoutFormStore'; @@ -160,15 +159,14 @@ async function billingDetailsSuccessLoader(queryClient: QueryClient): Promise Date: Fri, 10 Oct 2025 09:49:41 -0400 Subject: [PATCH 3/4] chore: swap error check validation logic to short ciruit on API response --- src/components/app/routes/loaders/checkoutStepperLoader.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/app/routes/loaders/checkoutStepperLoader.ts b/src/components/app/routes/loaders/checkoutStepperLoader.ts index dc8d7a52..b2918d09 100644 --- a/src/components/app/routes/loaders/checkoutStepperLoader.ts +++ b/src/components/app/routes/loaders/checkoutStepperLoader.ts @@ -166,8 +166,8 @@ async function billingDetailsSuccessLoader(queryClient: QueryClient): Promise Date: Fri, 10 Oct 2025 09:57:25 -0400 Subject: [PATCH 4/4] chore: fix lint and testing --- src/components/StatefulButton/StatefulSubscribeButton.tsx | 3 +-- .../plan-details-pages/tests/PlanDetailsPage.test.tsx | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/StatefulButton/StatefulSubscribeButton.tsx b/src/components/StatefulButton/StatefulSubscribeButton.tsx index cdaa6851..113d19fa 100644 --- a/src/components/StatefulButton/StatefulSubscribeButton.tsx +++ b/src/components/StatefulButton/StatefulSubscribeButton.tsx @@ -8,11 +8,10 @@ import { useQueryClient } from '@tanstack/react-query'; import { useContext, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { determineExistingSuccessfulCheckoutIntent } from '@/components/app/data/services/context'; import { useCheckoutIntent, usePolledCheckoutIntent } from '@/components/app/data'; import { termsAndConditions } from '@/components/app/data/constants'; -import { queryBffContext } from '@/components/app/data/queries/queries'; import { patchCheckoutIntent } from '@/components/app/data/services/checkout-intent'; +import { determineExistingSuccessfulCheckoutIntent } from '@/components/app/data/services/context'; import { CheckoutPageRoute, CheckoutSubstepKey, DataStoreKey } from '@/constants/checkout'; import EVENT_NAMES from '@/constants/events'; import { useCheckoutFormStore } from '@/hooks/useCheckoutFormStore'; diff --git a/src/components/plan-details-pages/tests/PlanDetailsPage.test.tsx b/src/components/plan-details-pages/tests/PlanDetailsPage.test.tsx index fe99688d..7c4dd567 100644 --- a/src/components/plan-details-pages/tests/PlanDetailsPage.test.tsx +++ b/src/components/plan-details-pages/tests/PlanDetailsPage.test.tsx @@ -170,7 +170,7 @@ describe('PlanDetailsPage - Admin Email Validation', () => { // Fill in required form fields using proper user interaction const fullNameInput = screen.getByLabelText(/full name/i); const adminEmailInput = screen.getByLabelText(/work email/i); - const quantityInput = screen.getByLabelText(/how many users/i); + const quantityInput = screen.getByLabelText(/number of licenses/i); const countrySelect = screen.getByLabelText(/country of residence/i); await user.type(fullNameInput, 'John Doe'); @@ -222,7 +222,7 @@ describe('PlanDetailsPage - Admin Email Validation', () => { // Fill in required form fields using proper user interaction const fullNameInput = screen.getByLabelText(/full name/i); const adminEmailInput = screen.getByLabelText(/work email/i); - const quantityInput = screen.getByLabelText(/how many users/i); + const quantityInput = screen.getByLabelText(/number of licenses/i); const countrySelect = screen.getByLabelText(/country of residence/i); await user.type(fullNameInput, 'John Doe'); @@ -271,7 +271,7 @@ describe('PlanDetailsPage - Admin Email Validation', () => { // Fill in required form fields using proper user interaction const fullNameInput = screen.getByLabelText(/full name/i); const adminEmailInput = screen.getByLabelText(/work email/i); - const quantityInput = screen.getByLabelText(/how many users/i); + const quantityInput = screen.getByLabelText(/number of licenses/i); const countrySelect = screen.getByLabelText(/country of residence/i); await user.type(fullNameInput, 'John Doe');