From dc8714b045b40fe99a36ed62bb61b68c2e27caf3 Mon Sep 17 00:00:00 2001
From: Dylan Staley <88163+dstaley@users.noreply.github.com>
Date: Wed, 25 Mar 2026 16:00:01 -0500
Subject: [PATCH 1/2] fix(localizations,ui): Disable switch to plan button when
over seat limit
---
packages/localizations/src/en-US.ts | 2 +
packages/shared/src/types/localization.ts | 1 +
.../PricingTable/PricingTableDefault.tsx | 29 +++-
.../__tests__/PricingTable.test.tsx | 163 ++++++++++++++++++
packages/ui/src/contexts/components/Plans.tsx | 20 ++-
packages/ui/src/utils/billingPlanSeats.ts | 47 +++++
6 files changed, 250 insertions(+), 12 deletions(-)
create mode 100644 packages/ui/src/utils/billingPlanSeats.ts
diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts
index 1e2b8ccb066..a2587d64bea 100644
--- a/packages/localizations/src/en-US.ts
+++ b/packages/localizations/src/en-US.ts
@@ -433,6 +433,8 @@ export const enUS: LocalizationResource = {
plansPage: {
alerts: {
noPermissionsToManageBilling: 'You do not have permissions to manage billing for this organization.',
+ planMembershipLimitExceeded:
+ 'Your organization has {{count}} members (including pending invitations). This plan only allows {{limit}} members.',
},
title: 'Plans',
},
diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts
index 0c471cf14ee..6cdf2f05ea5 100644
--- a/packages/shared/src/types/localization.ts
+++ b/packages/shared/src/types/localization.ts
@@ -1223,6 +1223,7 @@ export type __internal_LocalizationResource = {
title: LocalizationValue;
alerts: {
noPermissionsToManageBilling: LocalizationValue;
+ planMembershipLimitExceeded: LocalizationValue<'count' | 'limit'>;
};
};
apiKeysPage: {
diff --git a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx
index 3fbdf61fe74..d97c37e7c16 100644
--- a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx
+++ b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx
@@ -1,14 +1,15 @@
import { __internal_useOrganizationBase, useClerk, useSession } from '@clerk/shared/react';
import type {
BillingPlanResource,
+ BillingPlanUnitPrice,
BillingSubscriptionPlanPeriod,
PricingTableProps,
- BillingPlanUnitPrice,
} from '@clerk/shared/types';
import * as React from 'react';
import { Switch } from '@/ui/elements/Switch';
import { Tooltip } from '@/ui/elements/Tooltip';
+import { getPlanSeatLimit, getSeatUnitPrice, organizationExceedsPlanSeatLimit } from '@/ui/utils/billingPlanSeats';
import { getClosestProfileScrollBox } from '@/ui/utils/getClosestProfileScrollBox';
import { useProtect } from '../../common';
@@ -137,6 +138,22 @@ function Card(props: CardProps) {
[plan, planPeriod, activeOrUpcomingSubscriptionBasedOnPlanPeriod],
);
+ const footerButtonTooltipText = React.useMemo(() => {
+ if (isSignedIn && !canManageBilling) {
+ return localizationKeys('organizationProfile.plansPage.alerts.noPermissionsToManageBilling');
+ }
+
+ if (organization && subscriberType === 'organization' && organizationExceedsPlanSeatLimit(plan, organization)) {
+ const seatLimit = getPlanSeatLimit(plan);
+ return localizationKeys('organizationProfile.plansPage.alerts.planMembershipLimitExceeded', {
+ count: organization.membersCount + organization.pendingInvitationsCount,
+ limit: seatLimit as number,
+ });
+ }
+
+ return null;
+ }, [isSignedIn, canManageBilling, organization, subscriberType, plan]);
+
const hasFeatures = plan.features.length > 0;
const { shouldShowFooter, shouldShowFooterNotice } = getPricingFooterState({
@@ -247,17 +264,13 @@ function Card(props: CardProps) {
elementDescriptor={descriptors.pricingTableCardFooterButton}
block
textVariant={isCompact ? 'buttonSmall' : 'buttonLarge'}
- {...buttonPropsForPlan({ plan, isCompact, selectedPlanPeriod: planPeriod })}
+ {...buttonPropsForPlan({ plan, organization, isCompact, selectedPlanPeriod: planPeriod })}
onClick={event => {
onSelect(plan, event);
}}
/>
- {isSignedIn && !canManageBilling && (
-
- )}
+ {footerButtonTooltipText ? : null}
)}
@@ -595,7 +608,7 @@ const CardFeaturesListSeatCost = ({ plan }: { plan: BillingPlanResource }) => {
return null;
}
- const seatUnitPrice = unitPrices.find(unitPrice => unitPrice.name.toLowerCase() === 'seats') ?? unitPrices[0];
+ const seatUnitPrice = getSeatUnitPrice(plan);
if (!seatUnitPrice) {
return null;
diff --git a/packages/ui/src/components/PricingTable/__tests__/PricingTable.test.tsx b/packages/ui/src/components/PricingTable/__tests__/PricingTable.test.tsx
index 36ac5f13728..50ec2eb27a0 100644
--- a/packages/ui/src/components/PricingTable/__tests__/PricingTable.test.tsx
+++ b/packages/ui/src/components/PricingTable/__tests__/PricingTable.test.tsx
@@ -302,6 +302,47 @@ describe('PricingTable - plans visibility', () => {
pathRoot: '',
reload: vi.fn(),
} as const;
+ const seatLimitedOrganizationPlan = {
+ ...testPlan,
+ id: 'plan_org_target',
+ name: 'Organization Plan',
+ slug: 'organization-plan',
+ forPayerType: 'org',
+ unitPrices: [
+ {
+ name: 'seats',
+ blockSize: 1,
+ tiers: [
+ {
+ id: 'tier_org_target_1',
+ startsAtBlock: 1,
+ endsAfterBlock: 20,
+ feePerBlock: testPlan.fee,
+ },
+ ],
+ },
+ ],
+ } as const;
+ const currentOrganizationPlan = {
+ ...seatLimitedOrganizationPlan,
+ id: 'plan_org_current',
+ name: 'Current Organization Plan',
+ slug: 'current-organization-plan',
+ unitPrices: [
+ {
+ name: 'seats',
+ blockSize: 1,
+ tiers: [
+ {
+ id: 'tier_org_current_1',
+ startsAtBlock: 1,
+ endsAfterBlock: 50,
+ feePerBlock: testPlan.fee,
+ },
+ ],
+ },
+ ],
+ } as const;
it('shows no plans when user is signed in but has no subscription', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
@@ -581,6 +622,128 @@ describe('PricingTable - plans visibility', () => {
});
});
+ it('disables switching to an organization plan when the active org exceeds its seat limit', async () => {
+ const { wrapper, fixtures, props } = await createFixtures(f => {
+ f.withBilling();
+ f.withOrganizations();
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ organization_memberships: [
+ {
+ name: 'Org1',
+ permissions: ['org:sys_billing:manage'],
+ members_count: 17,
+ pending_invitations_count: 4,
+ },
+ ],
+ });
+ });
+
+ props.setProps({ for: 'organization' } as any);
+
+ fixtures.clerk.billing.getStatements.mockRejectedValue();
+ fixtures.clerk.organization.getPaymentMethods.mockRejectedValue();
+ fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [seatLimitedOrganizationPlan as any], total_count: 1 });
+ fixtures.clerk.billing.getSubscription.mockResolvedValue({
+ id: 'sub_org_active',
+ status: 'active',
+ activeAt: new Date('2021-01-01'),
+ createdAt: new Date('2021-01-01'),
+ nextPayment: null,
+ pastDueAt: null,
+ updatedAt: null,
+ subscriptionItems: [
+ {
+ id: 'si_org_active',
+ plan: currentOrganizationPlan,
+ createdAt: new Date('2021-01-01'),
+ paymentMethodId: 'src_1',
+ pastDueAt: null,
+ canceledAt: null,
+ periodStart: new Date('2021-01-01'),
+ periodEnd: new Date('2021-01-31'),
+ planPeriod: 'month' as const,
+ status: 'active' as const,
+ isFreeTrial: false,
+ cancel: vi.fn(),
+ pathRoot: '',
+ reload: vi.fn(),
+ },
+ ],
+ pathRoot: '',
+ reload: vi.fn(),
+ });
+
+ const { getByRole } = render(, { wrapper });
+
+ await waitFor(() => {
+ expect(getByRole('heading', { name: 'Organization Plan' })).toBeVisible();
+ });
+
+ expect(getByRole('button', { name: 'Switch to this plan' })).toBeDisabled();
+ });
+
+ it('keeps switching enabled when the active org is exactly at the plan seat limit', async () => {
+ const { wrapper, fixtures, props } = await createFixtures(f => {
+ f.withBilling();
+ f.withOrganizations();
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ organization_memberships: [
+ {
+ name: 'Org1',
+ permissions: ['org:sys_billing:manage'],
+ members_count: 17,
+ pending_invitations_count: 3,
+ },
+ ],
+ });
+ });
+
+ props.setProps({ for: 'organization' } as any);
+
+ fixtures.clerk.billing.getStatements.mockRejectedValue();
+ fixtures.clerk.organization.getPaymentMethods.mockRejectedValue();
+ fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [seatLimitedOrganizationPlan as any], total_count: 1 });
+ fixtures.clerk.billing.getSubscription.mockResolvedValue({
+ id: 'sub_org_active',
+ status: 'active',
+ activeAt: new Date('2021-01-01'),
+ createdAt: new Date('2021-01-01'),
+ nextPayment: null,
+ pastDueAt: null,
+ updatedAt: null,
+ subscriptionItems: [
+ {
+ id: 'si_org_active',
+ plan: currentOrganizationPlan,
+ createdAt: new Date('2021-01-01'),
+ paymentMethodId: 'src_1',
+ pastDueAt: null,
+ canceledAt: null,
+ periodStart: new Date('2021-01-01'),
+ periodEnd: new Date('2021-01-31'),
+ planPeriod: 'month' as const,
+ status: 'active' as const,
+ isFreeTrial: false,
+ cancel: vi.fn(),
+ pathRoot: '',
+ reload: vi.fn(),
+ },
+ ],
+ pathRoot: '',
+ reload: vi.fn(),
+ });
+
+ const { getByRole } = render(, { wrapper });
+
+ await waitFor(() => {
+ expect(getByRole('heading', { name: 'Organization Plan' })).toBeVisible();
+ });
+
+ expect(getByRole('button', { name: 'Switch to this plan' })).toBeEnabled();
+ });
+
it('fetches user plans and renders when using for: user', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withUser({ email_addresses: ['test@clerk.com'] });
diff --git a/packages/ui/src/contexts/components/Plans.tsx b/packages/ui/src/contexts/components/Plans.tsx
index 352533faf5f..9263453116a 100644
--- a/packages/ui/src/contexts/components/Plans.tsx
+++ b/packages/ui/src/contexts/components/Plans.tsx
@@ -12,11 +12,13 @@ import type {
BillingPlanResource,
BillingSubscriptionItemResource,
BillingSubscriptionPlanPeriod,
+ OrganizationResource,
} from '@clerk/shared/types';
import { useCallback, useMemo } from 'react';
import { useProtect } from '@/ui/common/Gate';
import { getClosestProfileScrollBox } from '@/ui/utils/getClosestProfileScrollBox';
+import { organizationExceedsPlanSeatLimit } from '@/ui/utils/billingPlanSeats';
import type { Appearance } from '../../internal/appearance';
import type { LocalizationKey } from '../../localization';
@@ -196,12 +198,14 @@ export const usePlansContext = () => {
const buttonPropsForPlan = useCallback(
({
plan,
+ organization,
// TODO(@COMMERCE): This needs to be removed.
subscription: sub,
isCompact = false,
selectedPlanPeriod = 'annual',
}: {
- plan?: BillingPlanResource;
+ plan: BillingPlanResource;
+ organization?: OrganizationResource | null;
subscription?: BillingSubscriptionItemResource;
isCompact?: boolean;
selectedPlanPeriod?: BillingSubscriptionPlanPeriod;
@@ -214,6 +218,8 @@ export const usePlansContext = () => {
} => {
const subscription =
sub ?? (plan ? activeOrUpcomingSubscriptionWithPlanPeriod(plan, selectedPlanPeriod) : undefined);
+ const exceedsPlanSeatLimit =
+ subscriberType === 'organization' && !!organization && organizationExceedsPlanSeatLimit(plan, organization);
let _selectedPlanPeriod = selectedPlanPeriod;
const isEligibleForSwitchToAnnual = Boolean(plan?.annualMonthlyFee);
@@ -279,11 +285,17 @@ export const usePlansContext = () => {
localizationKey: freeTrialOr(getLocalizationKey()),
variant: isCompact ? 'bordered' : 'solid',
colorScheme: isCompact ? 'secondary' : 'primary',
- isDisabled: !canManageBilling,
- disabled: !canManageBilling,
+ isDisabled: !canManageBilling || exceedsPlanSeatLimit,
+ disabled: !canManageBilling || exceedsPlanSeatLimit,
};
},
- [activeOrUpcomingSubscriptionWithPlanPeriod, canManageBilling, subscriptionItems, topLevelSubscription],
+ [
+ activeOrUpcomingSubscriptionWithPlanPeriod,
+ canManageBilling,
+ subscriberType,
+ subscriptionItems,
+ topLevelSubscription,
+ ],
);
const captionForSubscription = useCallback((subscription: BillingSubscriptionItemResource) => {
diff --git a/packages/ui/src/utils/billingPlanSeats.ts b/packages/ui/src/utils/billingPlanSeats.ts
new file mode 100644
index 00000000000..ffd1e2b6cbd
--- /dev/null
+++ b/packages/ui/src/utils/billingPlanSeats.ts
@@ -0,0 +1,47 @@
+import type { BillingPlanResource, BillingPlanUnitPrice, OrganizationResource } from '@clerk/shared/types';
+
+/**
+ * Given a plan, return the unit price for seats.
+ */
+export const getSeatUnitPrice = (plan: BillingPlanResource): BillingPlanUnitPrice | null => {
+ if (!plan.unitPrices?.length) {
+ return null;
+ }
+
+ const seatUnitPrice = plan.unitPrices.find(unitPrice => unitPrice.name === 'seats');
+
+ if (seatUnitPrice) {
+ return seatUnitPrice;
+ }
+
+ return null;
+};
+
+/**
+ * Given a plan, return the seat limit for the plan, or undefined if the plan does not have a seat limit.
+ */
+export const getPlanSeatLimit = (plan: BillingPlanResource): number | null | undefined => {
+ const seatUnitPrice = getSeatUnitPrice(plan);
+
+ if (!seatUnitPrice?.tiers.length) {
+ return undefined;
+ }
+
+ return seatUnitPrice.tiers[seatUnitPrice.tiers.length - 1]?.endsAfterBlock;
+};
+
+/**
+ * Given a plan and an organization, return true if the organization exceeds the seat limit for the plan.
+ */
+export const organizationExceedsPlanSeatLimit = (
+ plan: BillingPlanResource,
+ organization: OrganizationResource,
+): boolean => {
+ const seatLimit = getPlanSeatLimit(plan);
+
+ if (seatLimit === undefined || seatLimit === null) {
+ return false;
+ }
+
+ return organization.membersCount + organization.pendingInvitationsCount > seatLimit;
+};
From 34ae21e5221bf1dc53b0c2d98945752ec121dc8a Mon Sep 17 00:00:00 2001
From: Dylan Staley <88163+dstaley@users.noreply.github.com>
Date: Wed, 25 Mar 2026 16:16:47 -0500
Subject: [PATCH 2/2] fix(ui): sort imports
---
packages/ui/src/contexts/components/Plans.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/ui/src/contexts/components/Plans.tsx b/packages/ui/src/contexts/components/Plans.tsx
index 9263453116a..39a682d14ec 100644
--- a/packages/ui/src/contexts/components/Plans.tsx
+++ b/packages/ui/src/contexts/components/Plans.tsx
@@ -17,8 +17,8 @@ import type {
import { useCallback, useMemo } from 'react';
import { useProtect } from '@/ui/common/Gate';
-import { getClosestProfileScrollBox } from '@/ui/utils/getClosestProfileScrollBox';
import { organizationExceedsPlanSeatLimit } from '@/ui/utils/billingPlanSeats';
+import { getClosestProfileScrollBox } from '@/ui/utils/getClosestProfileScrollBox';
import type { Appearance } from '../../internal/appearance';
import type { LocalizationKey } from '../../localization';