From c9b59fef1ed4cea798c8861705732acedd5b8d52 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 3 Sep 2025 15:12:40 +0300 Subject: [PATCH 1/6] fix(clerk-js): Hide billing tab when appropriate --- .changeset/mighty-lions-cut.md | 5 ++++ .../OrganizationProfileRoutes.tsx | 4 ++-- .../UserProfile/UserProfileRoutes.tsx | 4 ++-- .../components/OrganizationProfile.ts | 23 ++++++++++++++++--- .../src/ui/contexts/components/UserProfile.ts | 21 +++++++++++++++-- .../src/ui/utils/createCustomPages.tsx | 9 ++++++-- 6 files changed, 55 insertions(+), 11 deletions(-) create mode 100644 .changeset/mighty-lions-cut.md diff --git a/.changeset/mighty-lions-cut.md b/.changeset/mighty-lions-cut.md new file mode 100644 index 00000000000..8ae212f221b --- /dev/null +++ b/.changeset/mighty-lions-cut.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Hide billing tab when no paid plans exist, the user does not have a current or past subscription. diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx index f153a991b93..62f5a72dfc0 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -38,7 +38,7 @@ const OrganizationPaymentAttemptPage = lazy(() => ); export const OrganizationProfileRoutes = () => { - const { pages, isMembersPageRoot, isGeneralPageRoot, isBillingPageRoot, isApiKeysPageRoot } = + const { pages, isMembersPageRoot, isGeneralPageRoot, isBillingPageRoot, isApiKeysPageRoot, shouldShowBilling } = useOrganizationProfileContext(); const { apiKeysSettings, commerceSettings } = useEnvironment(); @@ -83,7 +83,7 @@ export const OrganizationProfileRoutes = () => { - {commerceSettings.billing.organization.enabled ? ( + {commerceSettings.billing.organization.enabled && shouldShowBilling ? ( has({ permission: 'org:sys_billing:read' }) || has({ permission: 'org:sys_billing:manage' }) diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx index 76151e5b809..b137da3d65c 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx @@ -38,7 +38,7 @@ const PaymentAttemptPage = lazy(() => ); export const UserProfileRoutes = () => { - const { pages } = useUserProfileContext(); + const { pages, shouldShowBilling } = useUserProfileContext(); const { apiKeysSettings, commerceSettings } = useEnvironment(); const isAccountPageRoot = pages.routes[0].id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT; @@ -80,7 +80,7 @@ export const UserProfileRoutes = () => { - {commerceSettings.billing.user.enabled ? ( + {commerceSettings.billing.user.enabled && shouldShowBilling ? ( diff --git a/packages/clerk-js/src/ui/contexts/components/OrganizationProfile.ts b/packages/clerk-js/src/ui/contexts/components/OrganizationProfile.ts index 407f4cf1e31..26bdce3ec93 100644 --- a/packages/clerk-js/src/ui/contexts/components/OrganizationProfile.ts +++ b/packages/clerk-js/src/ui/contexts/components/OrganizationProfile.ts @@ -6,9 +6,10 @@ import type { CustomPageContent } from '@/ui/utils/createCustomPages'; import { createOrganizationProfileCustomPages } from '@/ui/utils/createCustomPages'; import { ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID } from '../../constants'; -import { useEnvironment } from '../../contexts'; import { useRouter } from '../../router'; import type { OrganizationProfileCtx } from '../../types'; +import { useEnvironment } from '../EnvironmentContext'; +import { useStatements, useSubscription } from './Plans'; type PagesType = { routes: NavbarRoute[]; @@ -24,6 +25,7 @@ export type OrganizationProfileContextType = OrganizationProfileCtx & { isGeneralPageRoot: boolean; isBillingPageRoot: boolean; isApiKeysPageRoot: boolean; + shouldShowBilling: boolean; }; export const OrganizationProfileContext = createContext(null); @@ -40,9 +42,23 @@ export const useOrganizationProfileContext = (): OrganizationProfileContextType const { componentName, customPages, ...ctx } = context; + const subscription = useSubscription(); + const statements = useStatements(); + + const hasNonFreeSubscription = subscription.data?.subscriptionItems.some(item => item.plan.hasBaseFee); + + // TODO(@BILLING): Remove this when C1s can disable user billing seperately from the organization billing. + const shouldShowBilling = + // The instance has at lease one visible plan the C2 can choose + environment.commerceSettings.billing.organization.hasPaidPlans || + // The C2 has a subscription, it can be active or past due, or scheduled for cancellation. + hasNonFreeSubscription || + // The C2 had a subscription in the past + Boolean(statements.data.length > 0); + const pages = useMemo( - () => createOrganizationProfileCustomPages(customPages || [], clerk, environment), - [customPages], + () => createOrganizationProfileCustomPages(customPages || [], clerk, shouldShowBilling, environment), + [customPages, shouldShowBilling], ); const navigateAfterLeaveOrganization = () => @@ -65,5 +81,6 @@ export const useOrganizationProfileContext = (): OrganizationProfileContextType isGeneralPageRoot, isBillingPageRoot, isApiKeysPageRoot, + shouldShowBilling, }; }; diff --git a/packages/clerk-js/src/ui/contexts/components/UserProfile.ts b/packages/clerk-js/src/ui/contexts/components/UserProfile.ts index 9ed47562e21..4ea08089fe3 100644 --- a/packages/clerk-js/src/ui/contexts/components/UserProfile.ts +++ b/packages/clerk-js/src/ui/contexts/components/UserProfile.ts @@ -9,6 +9,7 @@ import type { ParsedQueryString } from '../../router'; import { useRouter } from '../../router'; import type { UserProfileCtx } from '../../types'; import { useEnvironment } from '../EnvironmentContext'; +import { useStatements, useSubscription } from './Plans'; type PagesType = { routes: NavbarRoute[]; @@ -21,6 +22,7 @@ export type UserProfileContextType = UserProfileCtx & { authQueryString: string | null; pages: PagesType; shouldAllowIdentificationCreation: boolean; + shouldShowBilling: boolean; }; export const UserProfileContext = createContext(null); @@ -32,6 +34,20 @@ export const useUserProfileContext = (): UserProfileContextType => { const environment = useEnvironment(); const { user } = useUser(); + const subscription = useSubscription(); + const statements = useStatements(); + + const hasNonFreeSubscription = subscription.data?.subscriptionItems.some(item => item.plan.hasBaseFee); + + // TODO(@BILLING): Remove this when C1s can disable user billing seperately from the organization billing. + const shouldShowBilling = + // The instance has at lease one visible plan the C2 can choose + environment.commerceSettings.billing.user.hasPaidPlans || + // The C2 has a subscription, it can be active or past due, or scheduled for cancellation. + hasNonFreeSubscription || + // The C2 had a subscription in the past + Boolean(statements.data.length > 0); + if (!context || context.componentName !== 'UserProfile') { throw new Error('Clerk: useUserProfileContext called outside of the mounted UserProfile component.'); } @@ -39,8 +55,8 @@ export const useUserProfileContext = (): UserProfileContextType => { const { componentName, customPages, ...ctx } = context; const pages = useMemo(() => { - return createUserProfileCustomPages(customPages || [], clerk, environment); - }, [customPages]); + return createUserProfileCustomPages(customPages || [], clerk, shouldShowBilling, environment); + }, [customPages, shouldShowBilling]); const shouldAllowIdentificationCreation = useMemo(() => { const { enterpriseSSO } = environment.userSettings; @@ -62,5 +78,6 @@ export const useUserProfileContext = (): UserProfileContextType => { queryParams, authQueryString: '', shouldAllowIdentificationCreation, + shouldShowBilling, }; }; diff --git a/packages/clerk-js/src/ui/utils/createCustomPages.tsx b/packages/clerk-js/src/ui/utils/createCustomPages.tsx index ce9a45d0061..eec59a69c57 100644 --- a/packages/clerk-js/src/ui/utils/createCustomPages.tsx +++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx @@ -57,6 +57,7 @@ type CreateCustomPagesParams = { export const createUserProfileCustomPages = ( customPages: CustomPage[], clerk: LoadedClerk, + shouldShowBilling: boolean, environment?: EnvironmentResource, ) => { return createCustomPages( @@ -67,6 +68,7 @@ export const createUserProfileCustomPages = ( excludedPathsFromDuplicateWarning: [], }, clerk, + shouldShowBilling, environment, ); }; @@ -74,6 +76,7 @@ export const createUserProfileCustomPages = ( export const createOrganizationProfileCustomPages = ( customPages: CustomPage[], clerk: LoadedClerk, + shouldShowBilling: boolean, environment?: EnvironmentResource, ) => { return createCustomPages( @@ -84,6 +87,7 @@ export const createOrganizationProfileCustomPages = ( excludedPathsFromDuplicateWarning: [], }, clerk, + shouldShowBilling, environment, true, ); @@ -92,13 +96,14 @@ export const createOrganizationProfileCustomPages = ( const createCustomPages = ( { customPages, getDefaultRoutes, setFirstPathToRoot, excludedPathsFromDuplicateWarning }: CreateCustomPagesParams, clerk: LoadedClerk, + shouldShowBilling: boolean, environment?: EnvironmentResource, organization?: boolean, ) => { const { INITIAL_ROUTES, pageToRootNavbarRouteMap, validReorderItemLabels } = getDefaultRoutes({ commerce: organization - ? !disabledOrganizationBillingFeature(clerk, environment) - : !disabledUserBillingFeature(clerk, environment), + ? !disabledOrganizationBillingFeature(clerk, environment) && shouldShowBilling + : !disabledUserBillingFeature(clerk, environment) && shouldShowBilling, apiKeys: !disabledAPIKeysFeature(clerk, environment) && (organization ? canViewOrManageAPIKeys(clerk) : true), }); From 9be19d3ef555579ee176772c15f109374861ece3 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 3 Sep 2025 15:19:30 +0300 Subject: [PATCH 2/6] Patch hooks --- .changeset/grumpy-bags-bow.md | 5 +++++ packages/shared/src/react/hooks/createCommerceHook.tsx | 7 +++++-- packages/shared/src/react/hooks/useSubscription.tsx | 7 +++++-- 3 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 .changeset/grumpy-bags-bow.md diff --git a/.changeset/grumpy-bags-bow.md b/.changeset/grumpy-bags-bow.md new file mode 100644 index 00000000000..ab7c67ee37b --- /dev/null +++ b/.changeset/grumpy-bags-bow.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Disable billing hooks when the feature is turned off. diff --git a/packages/shared/src/react/hooks/createCommerceHook.tsx b/packages/shared/src/react/hooks/createCommerceHook.tsx index f63b57e48f6..d7c840a0c2b 100644 --- a/packages/shared/src/react/hooks/createCommerceHook.tsx +++ b/packages/shared/src/react/hooks/createCommerceHook.tsx @@ -1,4 +1,4 @@ -import type { ClerkPaginatedResponse, ClerkResource, ForPayerType } from '@clerk/types'; +import type { ClerkPaginatedResponse, ClerkResource, EnvironmentResource, ForPayerType } from '@clerk/types'; import { eventMethodCalled } from '../../telemetry/events/method-called'; import { @@ -66,6 +66,9 @@ export function createCommercePaginatedHook>( (hookParams || {}) as TParams, diff --git a/packages/shared/src/react/hooks/useSubscription.tsx b/packages/shared/src/react/hooks/useSubscription.tsx index 53efd1e2fd2..f8e39c4515c 100644 --- a/packages/shared/src/react/hooks/useSubscription.tsx +++ b/packages/shared/src/react/hooks/useSubscription.tsx @@ -1,4 +1,4 @@ -import type { ForPayerType } from '@clerk/types'; +import type { EnvironmentResource, ForPayerType } from '@clerk/types'; import { useCallback } from 'react'; import { eventMethodCalled } from '../../telemetry/events'; @@ -36,10 +36,13 @@ export const useSubscription = (params?: UseSubscriptionParams) => { const user = useUserContext(); const { organization } = useOrganizationContext(); + // @ts-expect-error `__unstable__environment` is not typed + const environment = clerk.__unstable__environment as unknown as EnvironmentResource | null | undefined; + clerk.telemetry?.record(eventMethodCalled(hookName)); const swr = useSWR( - user?.id + user?.id && environment?.commerceSettings.billing.user.enabled ? { type: 'commerce-subscription', userId: user.id, From 5047efe703cb29ef913f3cd8754eb5f9b1f53037 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 3 Sep 2025 16:03:03 +0300 Subject: [PATCH 3/6] add tests --- .../__tests__/UserProfile.test.tsx | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx index 5dd307c851d..937a331473d 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx @@ -1,7 +1,7 @@ import type { CustomPage } from '@clerk/types'; import { describe, it } from '@jest/globals'; -import { render, screen } from '../../../../testUtils'; +import { render, screen, waitFor } from '../../../../testUtils'; import { bindCreateFixtures } from '../../../utils/test/createFixtures'; import { UserProfile } from '../'; @@ -54,5 +54,83 @@ describe('UserProfile', () => { const externalElements = screen.getAllByRole('button', { name: /ExternalLink/i }); expect(externalElements.length).toBeGreaterThan(0); }); + + it('does not include Billing when user billing is disabled', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + fixtures.environment.commerceSettings.billing.user.enabled = false; + + render(, { wrapper }); + await waitFor(() => expect(screen.queryByRole('button', { name: /Billing/i })).toBeNull()); + }); + + it('includes Billing when enabled and instance has paid plans', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + fixtures.environment.commerceSettings.billing.user.enabled = true; + fixtures.environment.commerceSettings.billing.user.hasPaidPlans = true; + + render(, { wrapper }); + const billingElements = await screen.findAllByRole('button', { name: /Billing/i }); + expect(billingElements.length).toBeGreaterThan(0); + }); + + it('includes Billing when enabled and user has a non-free subscription', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + fixtures.environment.commerceSettings.billing.user.enabled = true; + fixtures.environment.commerceSettings.billing.user.hasPaidPlans = false; + + fixtures.clerk.billing.getSubscription.mockResolvedValue({ + id: 'sub_top', + subscriptionItems: [ + { + id: 'sub_item_1', + plan: { hasBaseFee: true }, + }, + ], + } as any); + + render(, { wrapper }); + const billingElements = await screen.findAllByRole('button', { name: /Billing/i }); + expect(billingElements.length).toBeGreaterThan(0); + }); + + it('includes Billing when enabled and user has past statements', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + fixtures.environment.commerceSettings.billing.user.enabled = true; + fixtures.environment.commerceSettings.billing.user.hasPaidPlans = false; + + fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_top', subscriptionItems: [] } as any); + fixtures.clerk.billing.getStatements.mockResolvedValue({ data: [{}], total_count: 1 } as any); + + render(, { wrapper }); + const billingElements = await screen.findAllByRole('button', { name: /Billing/i }); + expect(billingElements.length).toBeGreaterThan(0); + }); + + it('does not include Billing when enabled but no paid plans, no subscription, and no statements', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + fixtures.environment.commerceSettings.billing.user.enabled = true; + fixtures.environment.commerceSettings.billing.user.hasPaidPlans = false; + + fixtures.clerk.billing.getSubscription.mockResolvedValue({ id: 'sub_top', subscriptionItems: [] } as any); + fixtures.clerk.billing.getStatements.mockResolvedValue({ data: [], total_count: 0 } as any); + + render(, { wrapper }); + await waitFor(() => expect(screen.queryByRole('button', { name: /Billing/i })).toBeNull()); + }); }); }); From 7774ece53bd185659c271b5723cad34dca7ca7da Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 3 Sep 2025 16:43:43 +0300 Subject: [PATCH 4/6] add billing to fixtures --- .../Checkout/__tests__/Checkout.test.tsx | 10 ++++++++ .../__tests__/PricingTable.test.tsx | 19 ++++++++++++--- .../__tests__/SubscriptionDetails.test.tsx | 12 ++++++++++ .../__tests__/SubscriptionsList.test.tsx | 4 ++++ .../src/ui/utils/test/fixtureHelpers.ts | 16 +++++++++++++ .../clerk-js/src/ui/utils/test/fixtures.ts | 23 +++++++++++++++++++ 6 files changed, 81 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx b/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx index fc63fd5000f..89844ef1ad7 100644 --- a/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx @@ -27,6 +27,7 @@ describe('Checkout', () => { it('displays spinner when checkout is initializing', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); // Mock billing to prevent actual API calls and stay in loading state @@ -98,6 +99,7 @@ describe('Checkout', () => { it('handles checkout initialization errors gracefully', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); // Mock billing to reject with a Clerk-like error shape @@ -129,6 +131,7 @@ describe('Checkout', () => { it('displays proper loading state during checkout initialization', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); // Mock billing to stay in loading state @@ -161,6 +164,7 @@ describe('Checkout', () => { it('maintains accessibility attributes correctly', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); fixtures.clerk.billing.startCheckout.mockImplementation(() => new Promise(() => {})); @@ -201,6 +205,7 @@ describe('Checkout', () => { it('renders without crashing when all required props are provided', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); // Mock billing to prevent actual API calls @@ -228,6 +233,7 @@ describe('Checkout', () => { it('renders without errors for monthly period', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); fixtures.clerk.billing.startCheckout.mockImplementation(() => new Promise(() => {})); const { baseElement } = render( @@ -272,6 +278,7 @@ describe('Checkout', () => { it('renders with correct CSS classes and structure', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); fixtures.clerk.billing.startCheckout.mockImplementation(() => new Promise(() => {})); @@ -305,6 +312,7 @@ describe('Checkout', () => { it('renders free trial details during confirmation stage', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); const freeTrialEndsAt = new Date('2025-08-19'); @@ -388,6 +396,7 @@ describe('Checkout', () => { it('renders trial success details in completed stage', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); const freeTrialEndsAt = new Date('2025-08-19'); @@ -469,6 +478,7 @@ describe('Checkout', () => { it('renders existing payment sources during checkout confirmation', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); fixtures.clerk.user?.getPaymentSources.mockResolvedValue({ diff --git a/packages/clerk-js/src/ui/components/PricingTable/__tests__/PricingTable.test.tsx b/packages/clerk-js/src/ui/components/PricingTable/__tests__/PricingTable.test.tsx index 9422c13d319..1184de42e8c 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/__tests__/PricingTable.test.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/__tests__/PricingTable.test.tsx @@ -44,6 +44,7 @@ describe('PricingTable - trial info', () => { it('shows footer notice with trial end date when active subscription is in free trial', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { + f.withBilling(); f.withUser({ email_addresses: ['test@clerk.com'] }); }); @@ -99,6 +100,7 @@ describe('PricingTable - trial info', () => { it('shows CTA "Start N-day free trial" when eligible and plan has trial', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); // Provide empty props to the PricingTable context @@ -130,7 +132,9 @@ describe('PricingTable - trial info', () => { }); it('shows CTA "Start N-day free trial" when user is signed out and plan has trial', async () => { - const { wrapper, fixtures, props } = await createFixtures(); + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withBilling(); + }); // Provide empty props to the PricingTable context props.setProps({}); @@ -149,7 +153,9 @@ describe('PricingTable - trial info', () => { }); it('shows CTA "Subscribe" when user is signed out and plan has no trial', async () => { - const { wrapper, fixtures, props } = await createFixtures(); + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withBilling(); + }); const nonTrialPlan = { ...trialPlan, @@ -178,6 +184,7 @@ describe('PricingTable - trial info', () => { it('shows footer notice with "starts at" when subscription is upcoming and not a free trial', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); // Provide empty props to the PricingTable context @@ -301,6 +308,7 @@ describe('PricingTable - plans visibility', () => { it('shows plans when user is signed in and has a subscription', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); // Provide empty props to the PricingTable context @@ -347,7 +355,9 @@ describe('PricingTable - plans visibility', () => { }); it('shows plans when user is signed out', async () => { - const { wrapper, fixtures, props } = await createFixtures(); + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withBilling(); + }); // Provide empty props to the PricingTable context props.setProps({}); @@ -367,6 +377,7 @@ describe('PricingTable - plans visibility', () => { it('shows no plans when user is signed in but subscription is null', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); // Provide empty props to the PricingTable context @@ -387,6 +398,7 @@ describe('PricingTable - plans visibility', () => { it('shows no plans when user is signed in but subscription is undefined', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); // Provide empty props to the PricingTable context @@ -407,6 +419,7 @@ describe('PricingTable - plans visibility', () => { it('prevents flicker by not showing plans while subscription is loading', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); // Provide empty props to the PricingTable context diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx index 0f70e3f2896..5cf30747e3a 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx @@ -10,6 +10,7 @@ describe('SubscriptionDetails', () => { it('Displays spinner when init loading', async () => { const { wrapper } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); const { baseElement } = render( @@ -31,6 +32,7 @@ describe('SubscriptionDetails', () => { it('single active monthly subscription', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); fixtures.clerk.billing.getSubscription.mockResolvedValue({ @@ -134,6 +136,7 @@ describe('SubscriptionDetails', () => { it('single active annual subscription', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); fixtures.clerk.billing.getSubscription.mockResolvedValue({ @@ -237,6 +240,7 @@ describe('SubscriptionDetails', () => { it('active free subscription', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); fixtures.clerk.billing.getSubscription.mockResolvedValue({ @@ -319,6 +323,7 @@ describe('SubscriptionDetails', () => { it('one active annual and one upcoming monthly subscription', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); const planAnnual = { @@ -478,6 +483,7 @@ describe('SubscriptionDetails', () => { it('one active and one upcoming FREE subscription', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); const planMonthly = { @@ -614,6 +620,7 @@ describe('SubscriptionDetails', () => { it('allows cancelling a subscription of a monthly plan', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); const cancelSubscriptionMock = jest.fn().mockResolvedValue({}); @@ -718,6 +725,7 @@ describe('SubscriptionDetails', () => { it('calls resubscribe when the user clicks Resubscribe for a canceled subscription', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); const plan = { @@ -820,6 +828,7 @@ describe('SubscriptionDetails', () => { it('calls switchToMonthly when the user clicks Switch to monthly for an annual subscription', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); const plan = { @@ -925,6 +934,7 @@ describe('SubscriptionDetails', () => { it('past due subscription shows correct status and disables actions', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); const plan = { @@ -1017,6 +1027,7 @@ describe('SubscriptionDetails', () => { it('active free trial subscription shows correct labels and behavior', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); fixtures.clerk.billing.getSubscription.mockResolvedValue({ @@ -1125,6 +1136,7 @@ describe('SubscriptionDetails', () => { it('allows cancelling a free trial with specific dialog text', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); const cancelSubscriptionMock = jest.fn().mockResolvedValue({}); diff --git a/packages/clerk-js/src/ui/components/Subscriptions/__tests__/SubscriptionsList.test.tsx b/packages/clerk-js/src/ui/components/Subscriptions/__tests__/SubscriptionsList.test.tsx index ec12c7095cd..bc4a52cd24e 100644 --- a/packages/clerk-js/src/ui/components/Subscriptions/__tests__/SubscriptionsList.test.tsx +++ b/packages/clerk-js/src/ui/components/Subscriptions/__tests__/SubscriptionsList.test.tsx @@ -19,6 +19,7 @@ describe('SubscriptionsList', () => { it('displays free trial badge when subscription is in free trial', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); const freeTrialSubscription = { @@ -82,6 +83,7 @@ describe('SubscriptionsList', () => { it('on past due, no badge, but past due date is shown', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); const pastDueSubscription = { @@ -146,6 +148,7 @@ describe('SubscriptionsList', () => { it('does not display active badge when subscription is active and it is a single item', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); const activeSubscription = { @@ -209,6 +212,7 @@ describe('SubscriptionsList', () => { it('renders upcomming badge when current subscription is canceled but active', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); }); const upcomingSubscription = { diff --git a/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts b/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts index 97020140de2..b0991810ca1 100644 --- a/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts +++ b/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts @@ -29,6 +29,7 @@ export const createEnvironmentFixtureHelpers = (baseEnvironment: EnvironmentJSON ...createDisplayConfigFixtureHelpers(baseEnvironment), ...createOrganizationSettingsFixtureHelpers(baseEnvironment), ...createUserSettingsFixtureHelpers(baseEnvironment), + ...createBillingSettingsFixtureHelpers(baseEnvironment), }; }; @@ -349,6 +350,21 @@ const createOrganizationSettingsFixtureHelpers = (environment: EnvironmentJSON) return { withOrganizations, withMaxAllowedMemberships, withOrganizationDomains, withForceOrganizationSelection }; }; +const createBillingSettingsFixtureHelpers = (environment: EnvironmentJSON) => { + const os = environment.commerce_settings.billing; + const withBilling = () => { + os.enabled = true; + os.user.enabled = true; + os.user.has_paid_plans = true; + os.organization.enabled = true; + os.organization.has_paid_plans = true; + os.has_paid_org_plans = true; + os.has_paid_user_plans = true; + }; + + return { withBilling }; +}; + const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { const us = environment.user_settings; us.password_settings = { diff --git a/packages/clerk-js/src/ui/utils/test/fixtures.ts b/packages/clerk-js/src/ui/utils/test/fixtures.ts index 9eb97488214..9e606c4181d 100644 --- a/packages/clerk-js/src/ui/utils/test/fixtures.ts +++ b/packages/clerk-js/src/ui/utils/test/fixtures.ts @@ -1,6 +1,7 @@ import type { AuthConfigJSON, ClientJSON, + CommerceSettingsJSON, DisplayConfigJSON, EnvironmentJSON, OrganizationSettingsJSON, @@ -18,6 +19,7 @@ export const createBaseEnvironmentJSON = (): EnvironmentJSON => { display_config: createBaseDisplayConfig(), organization_settings: createBaseOrganizationSettings(), user_settings: createBaseUserSettings(), + commerce_settings: createBaseCommerceSettings(), meta: { responseHeaders: { country: 'us' } }, }; }; @@ -206,6 +208,27 @@ const createBaseUserSettings = (): UserSettingsJSON => { }; }; +const createBaseCommerceSettings = (): CommerceSettingsJSON => { + return { + object: 'commerce_settings', + id: 'commerce_settings_1', + billing: { + enabled: false, + user: { + enabled: false, + has_paid_plans: false, + }, + organization: { + enabled: false, + has_paid_plans: false, + }, + has_paid_org_plans: false, + has_paid_user_plans: false, + stripe_publishable_key: '', + }, + }; +}; + export const createBaseClientJSON = (): ClientJSON => { return {} as ClientJSON; }; From dfce2a608775fdfdb91a995dccf10925d5b8a2e0 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 3 Sep 2025 16:55:53 +0300 Subject: [PATCH 5/6] improve enablement logic --- packages/shared/src/react/hooks/createCommerceHook.tsx | 7 ++++++- packages/shared/src/react/hooks/useSubscription.tsx | 9 +++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/react/hooks/createCommerceHook.tsx b/packages/shared/src/react/hooks/createCommerceHook.tsx index d7c840a0c2b..2db5096ab82 100644 --- a/packages/shared/src/react/hooks/createCommerceHook.tsx +++ b/packages/shared/src/react/hooks/createCommerceHook.tsx @@ -85,7 +85,12 @@ export function createCommercePaginatedHook>( (hookParams || {}) as TParams, diff --git a/packages/shared/src/react/hooks/useSubscription.tsx b/packages/shared/src/react/hooks/useSubscription.tsx index f8e39c4515c..a9f03ea96a9 100644 --- a/packages/shared/src/react/hooks/useSubscription.tsx +++ b/packages/shared/src/react/hooks/useSubscription.tsx @@ -41,12 +41,17 @@ export const useSubscription = (params?: UseSubscriptionParams) => { clerk.telemetry?.record(eventMethodCalled(hookName)); + const isOrganization = params?.for === 'organization'; + const billingEnabled = isOrganization + ? environment?.commerceSettings.billing.organization.enabled + : environment?.commerceSettings.billing.user.enabled; + const swr = useSWR( - user?.id && environment?.commerceSettings.billing.user.enabled + user?.id && billingEnabled ? { type: 'commerce-subscription', userId: user.id, - args: { orgId: params?.for === 'organization' ? organization?.id : undefined }, + args: { orgId: isOrganization ? organization?.id : undefined }, } : null, ({ args }) => clerk.billing.getSubscription(args), From 3bc2b34088f612ef8d0192f90406d43d1e43bfc8 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 3 Sep 2025 17:33:21 +0300 Subject: [PATCH 6/6] apply SubscriberTypeContext --- .../src/ui/components/OrganizationProfile/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/index.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/index.tsx index c5b7a1cec45..5413c2c27b3 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/index.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/index.tsx @@ -7,7 +7,7 @@ import { NavbarMenuButtonRow } from '@/ui/elements/Navbar'; import { ProfileCard } from '@/ui/elements/ProfileCard'; import { ORGANIZATION_PROFILE_CARD_SCROLLBOX_ID } from '../../constants'; -import { OrganizationProfileContext, withCoreUserGuard } from '../../contexts'; +import { OrganizationProfileContext, SubscriberTypeContext, withCoreUserGuard } from '../../contexts'; import { Flow, localizationKeys } from '../../customizables'; import { Route, Switch } from '../../router'; import type { OrganizationProfileCtx } from '../../types'; @@ -26,7 +26,9 @@ const _OrganizationProfile = (_: OrganizationProfileProps) => { - + + +