From 2c7ba6ec51d2be4c58839591517a6b9d07c46bfd Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Tue, 30 Sep 2025 23:41:08 -0400 Subject: [PATCH 1/2] feat: customer billing portal POC --- .../PurchaseSummary/PurchaseSummary.tsx | 4 ++++ .../PurchaseSummary/ReceiptButton.tsx | 20 +++++++++++++++++++ src/components/app/routes/loaders/utils.ts | 2 +- src/types/types.d.ts | 10 +++++++++- 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 src/components/PurchaseSummary/ReceiptButton.tsx diff --git a/src/components/PurchaseSummary/PurchaseSummary.tsx b/src/components/PurchaseSummary/PurchaseSummary.tsx index 8fed7385..f1114baa 100644 --- a/src/components/PurchaseSummary/PurchaseSummary.tsx +++ b/src/components/PurchaseSummary/PurchaseSummary.tsx @@ -2,6 +2,7 @@ import { Card, Stack } from '@openedx/paragon'; import React from 'react'; import { usePurchaseSummaryPricing } from '@/components/app/data'; +import ReceiptButton from '@/components/PurchaseSummary/ReceiptButton'; import { DataStoreKey } from '@/constants/checkout'; import { useCheckoutFormStore } from '@/hooks/index'; @@ -36,6 +37,9 @@ const PurchaseSummary: React.FC = () => { + + + ); }; diff --git a/src/components/PurchaseSummary/ReceiptButton.tsx b/src/components/PurchaseSummary/ReceiptButton.tsx new file mode 100644 index 00000000..68ca2714 --- /dev/null +++ b/src/components/PurchaseSummary/ReceiptButton.tsx @@ -0,0 +1,20 @@ +import { AppContext } from '@edx/frontend-platform/react'; +import { Button } from '@openedx/paragon'; +import { useContext } from 'react'; + +import useBFFSuccess from '@/components/app/data/hooks/useBFFSuccess'; + +const ReceiptButton = () => { + const { authenticatedUser }: AppContextValue = useContext(AppContext); + const { data: contextData } = useBFFSuccess(authenticatedUser.userId); + const { checkoutIntent } = contextData ?? {}; + + // TODO: Stub button + return ( + + ); +}; + +export default ReceiptButton; diff --git a/src/components/app/routes/loaders/utils.ts b/src/components/app/routes/loaders/utils.ts index 91402b22..87e85d67 100644 --- a/src/components/app/routes/loaders/utils.ts +++ b/src/components/app/routes/loaders/utils.ts @@ -43,7 +43,7 @@ interface DetermineExistingPaidCheckoutIntent { * Object indicating if a successful intent exists and if the intent is expired. */ const determineExistingCheckoutIntentState = ( - checkoutIntent: ExtendedCheckoutContextCheckoutIntent | null, + checkoutIntent: CheckoutContextCheckoutIntent & CheckoutContextCheckoutIntentSuccess | null, ): DetermineExistingPaidCheckoutIntent => { if (!checkoutIntent) { return { diff --git a/src/types/types.d.ts b/src/types/types.d.ts index 7d37a61c..56104079 100644 --- a/src/types/types.d.ts +++ b/src/types/types.d.ts @@ -292,7 +292,15 @@ declare global { adminPortalUrl: string; } - interface ExtendedCheckoutContextCheckoutIntent extends CheckoutContextCheckoutIntent { + // TODO: will need to be modified further to include missing fields from the API response + interface CheckoutContextCheckoutIntentSuccess extends CheckoutContextCheckoutIntent { + stripeCustomerId: string; + enterpriseCustomerUuid: string | null; + } + + interface ExtendedCheckoutContextCheckoutIntent extends + CheckoutContextCheckoutIntent, + CheckoutContextCheckoutIntentSuccess { existingSuccessfulCheckoutIntent: boolean | null; expiredCheckoutIntent: boolean | null; } From f3af9042ee5bf66ee1aece9034b29add0ae7c67a Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Wed, 1 Oct 2025 01:11:46 -0400 Subject: [PATCH 2/2] feat: add billing portal API logic --- .../PurchaseSummary/ReceiptButton.tsx | 10 +++------ src/components/app/data/hooks/index.ts | 1 + .../hooks/useCreateBillingPortalSession.tsx | 21 +++++++++++++++++++ src/components/app/data/queries/queries.ts | 6 ++++++ .../app/data/queries/queryKeyFactory.ts | 5 +++++ .../data/services/create-billing-portal.ts | 15 +++++++++++++ 6 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 src/components/app/data/hooks/useCreateBillingPortalSession.tsx create mode 100644 src/components/app/data/services/create-billing-portal.ts diff --git a/src/components/PurchaseSummary/ReceiptButton.tsx b/src/components/PurchaseSummary/ReceiptButton.tsx index 68ca2714..2894abaa 100644 --- a/src/components/PurchaseSummary/ReceiptButton.tsx +++ b/src/components/PurchaseSummary/ReceiptButton.tsx @@ -1,17 +1,13 @@ -import { AppContext } from '@edx/frontend-platform/react'; import { Button } from '@openedx/paragon'; -import { useContext } from 'react'; -import useBFFSuccess from '@/components/app/data/hooks/useBFFSuccess'; +import { useCreateBillingPortalSession } from '@/components/app/data'; const ReceiptButton = () => { - const { authenticatedUser }: AppContextValue = useContext(AppContext); - const { data: contextData } = useBFFSuccess(authenticatedUser.userId); - const { checkoutIntent } = contextData ?? {}; + const { data: billingPortalSession } = useCreateBillingPortalSession(); // TODO: Stub button return ( - ); diff --git a/src/components/app/data/hooks/index.ts b/src/components/app/data/hooks/index.ts index b6b6507b..2adbc946 100644 --- a/src/components/app/data/hooks/index.ts +++ b/src/components/app/data/hooks/index.ts @@ -5,3 +5,4 @@ export { default as useLoginMutation } from './useLoginMutation'; export { default as useCreateCheckoutSessionMutation } from './useCreateCheckoutSessionMutation'; export { default as usePurchaseSummaryPricing } from './usePurchaseSummaryPricing'; export { default as useCheckoutIntent } from './useCheckoutIntent'; +export { default as useCreateBillingPortalSession } from './useCreateBillingPortalSession'; diff --git a/src/components/app/data/hooks/useCreateBillingPortalSession.tsx b/src/components/app/data/hooks/useCreateBillingPortalSession.tsx new file mode 100644 index 00000000..6c5775f4 --- /dev/null +++ b/src/components/app/data/hooks/useCreateBillingPortalSession.tsx @@ -0,0 +1,21 @@ +import { AppContext } from '@edx/frontend-platform/react'; +import { queryOptions, useQuery } from '@tanstack/react-query'; +import { useContext } from 'react'; + +import useBFFSuccess from '@/components/app/data/hooks/useBFFSuccess'; +import { queryCreateBillingPortalSession } from '@/components/app/data/queries/queries'; + +const useCreateBillingPortalSession = (options = {}) => { + const { authenticatedUser }: AppContextValue = useContext(AppContext); + const { data: contextData } = useBFFSuccess(authenticatedUser.userId); + const { checkoutIntent } = contextData ?? {}; + return useQuery( + queryOptions({ + ...queryCreateBillingPortalSession(checkoutIntent?.id), + ...options, + enabled: !!checkoutIntent?.id, + }), + ); +}; + +export default useCreateBillingPortalSession; diff --git a/src/components/app/data/queries/queries.ts b/src/components/app/data/queries/queries.ts index 63acea71..c525febe 100644 --- a/src/components/app/data/queries/queries.ts +++ b/src/components/app/data/queries/queries.ts @@ -33,3 +33,9 @@ export const queryBffValidation = (payload: ValidationSchema) => { ._ctx.validation(fields, snakeCasedPayload) ); }; + +export const queryCreateBillingPortalSession = (checkout_intent_id?: number) => ( + queries + .enterpriseCheckout + .createBillingPortalSession(checkout_intent_id) +); diff --git a/src/components/app/data/queries/queryKeyFactory.ts b/src/components/app/data/queries/queryKeyFactory.ts index 59c042b1..c43667ef 100644 --- a/src/components/app/data/queries/queryKeyFactory.ts +++ b/src/components/app/data/queries/queryKeyFactory.ts @@ -2,6 +2,7 @@ import { createQueryKeys, mergeQueryKeys } from '@lukemorales/query-key-factory' import createCheckoutSession from '@/components/app/data/services/checkout-session'; import { fetchCheckoutContext, fetchCheckoutSuccess } from '@/components/app/data/services/context'; +import createBillingPortalSession from '@/components/app/data/services/create-billing-portal'; import fetchCheckoutValidation from '@/components/app/data/services/validation'; const enterpriseCheckout = createQueryKeys('enterpriseCheckout', { @@ -26,6 +27,10 @@ const enterpriseCheckout = createQueryKeys('enterpriseCheckout', { queryKey: [fields], queryFn: () => createCheckoutSession(payload), }), + createBillingPortalSession: (checkout_intent_id) => ({ + queryKey: [checkout_intent_id], + queryFn: () => createBillingPortalSession(checkout_intent_id), + }), }); const queries = mergeQueryKeys(enterpriseCheckout); diff --git a/src/components/app/data/services/create-billing-portal.ts b/src/components/app/data/services/create-billing-portal.ts new file mode 100644 index 00000000..a6866f57 --- /dev/null +++ b/src/components/app/data/services/create-billing-portal.ts @@ -0,0 +1,15 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform/config'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; + +const createBillingPortalSession = async (checkoutIntentId) => { + const { ENTERPRISE_ACCESS_BASE_URL } = getConfig(); + if (!checkoutIntentId) { + return null; + } + const url = `${ENTERPRISE_ACCESS_BASE_URL}/api/v1/customer-billing/${checkoutIntentId}/create-portal-session`; + const response = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(response.data); +}; + +export default createBillingPortalSession;