Skip to content

Commit d9234e5

Browse files
authored
fix(localizations,ui): Disable switch to plan button when over seat limit (#8165)
1 parent a1d50a5 commit d9234e5

6 files changed

Lines changed: 250 additions & 12 deletions

File tree

packages/localizations/src/en-US.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,8 @@ export const enUS: LocalizationResource = {
433433
plansPage: {
434434
alerts: {
435435
noPermissionsToManageBilling: 'You do not have permissions to manage billing for this organization.',
436+
planMembershipLimitExceeded:
437+
'Your organization has {{count}} members (including pending invitations). This plan only allows {{limit}} members.',
436438
},
437439
title: 'Plans',
438440
},

packages/shared/src/types/localization.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1223,6 +1223,7 @@ export type __internal_LocalizationResource = {
12231223
title: LocalizationValue;
12241224
alerts: {
12251225
noPermissionsToManageBilling: LocalizationValue;
1226+
planMembershipLimitExceeded: LocalizationValue<'count' | 'limit'>;
12261227
};
12271228
};
12281229
apiKeysPage: {

packages/ui/src/components/PricingTable/PricingTableDefault.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { __internal_useOrganizationBase, useClerk, useSession } from '@clerk/shared/react';
22
import type {
33
BillingPlanResource,
4+
BillingPlanUnitPrice,
45
BillingSubscriptionPlanPeriod,
56
PricingTableProps,
6-
BillingPlanUnitPrice,
77
} from '@clerk/shared/types';
88
import * as React from 'react';
99

1010
import { Switch } from '@/ui/elements/Switch';
1111
import { Tooltip } from '@/ui/elements/Tooltip';
12+
import { getPlanSeatLimit, getSeatUnitPrice, organizationExceedsPlanSeatLimit } from '@/ui/utils/billingPlanSeats';
1213
import { getClosestProfileScrollBox } from '@/ui/utils/getClosestProfileScrollBox';
1314

1415
import { useProtect } from '../../common';
@@ -137,6 +138,22 @@ function Card(props: CardProps) {
137138
[plan, planPeriod, activeOrUpcomingSubscriptionBasedOnPlanPeriod],
138139
);
139140

141+
const footerButtonTooltipText = React.useMemo(() => {
142+
if (isSignedIn && !canManageBilling) {
143+
return localizationKeys('organizationProfile.plansPage.alerts.noPermissionsToManageBilling');
144+
}
145+
146+
if (organization && subscriberType === 'organization' && organizationExceedsPlanSeatLimit(plan, organization)) {
147+
const seatLimit = getPlanSeatLimit(plan);
148+
return localizationKeys('organizationProfile.plansPage.alerts.planMembershipLimitExceeded', {
149+
count: organization.membersCount + organization.pendingInvitationsCount,
150+
limit: seatLimit as number,
151+
});
152+
}
153+
154+
return null;
155+
}, [isSignedIn, canManageBilling, organization, subscriberType, plan]);
156+
140157
const hasFeatures = plan.features.length > 0;
141158

142159
const { shouldShowFooter, shouldShowFooterNotice } = getPricingFooterState({
@@ -247,17 +264,13 @@ function Card(props: CardProps) {
247264
elementDescriptor={descriptors.pricingTableCardFooterButton}
248265
block
249266
textVariant={isCompact ? 'buttonSmall' : 'buttonLarge'}
250-
{...buttonPropsForPlan({ plan, isCompact, selectedPlanPeriod: planPeriod })}
267+
{...buttonPropsForPlan({ plan, organization, isCompact, selectedPlanPeriod: planPeriod })}
251268
onClick={event => {
252269
onSelect(plan, event);
253270
}}
254271
/>
255272
</Tooltip.Trigger>
256-
{isSignedIn && !canManageBilling && (
257-
<Tooltip.Content
258-
text={localizationKeys('organizationProfile.plansPage.alerts.noPermissionsToManageBilling')}
259-
/>
260-
)}
273+
{footerButtonTooltipText ? <Tooltip.Content text={footerButtonTooltipText} /> : null}
261274
</Tooltip.Root>
262275
)}
263276
</Box>
@@ -595,7 +608,7 @@ const CardFeaturesListSeatCost = ({ plan }: { plan: BillingPlanResource }) => {
595608
return null;
596609
}
597610

598-
const seatUnitPrice = unitPrices.find(unitPrice => unitPrice.name.toLowerCase() === 'seats') ?? unitPrices[0];
611+
const seatUnitPrice = getSeatUnitPrice(plan);
599612

600613
if (!seatUnitPrice) {
601614
return null;

packages/ui/src/components/PricingTable/__tests__/PricingTable.test.tsx

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,47 @@ describe('PricingTable - plans visibility', () => {
302302
pathRoot: '',
303303
reload: vi.fn(),
304304
} as const;
305+
const seatLimitedOrganizationPlan = {
306+
...testPlan,
307+
id: 'plan_org_target',
308+
name: 'Organization Plan',
309+
slug: 'organization-plan',
310+
forPayerType: 'org',
311+
unitPrices: [
312+
{
313+
name: 'seats',
314+
blockSize: 1,
315+
tiers: [
316+
{
317+
id: 'tier_org_target_1',
318+
startsAtBlock: 1,
319+
endsAfterBlock: 20,
320+
feePerBlock: testPlan.fee,
321+
},
322+
],
323+
},
324+
],
325+
} as const;
326+
const currentOrganizationPlan = {
327+
...seatLimitedOrganizationPlan,
328+
id: 'plan_org_current',
329+
name: 'Current Organization Plan',
330+
slug: 'current-organization-plan',
331+
unitPrices: [
332+
{
333+
name: 'seats',
334+
blockSize: 1,
335+
tiers: [
336+
{
337+
id: 'tier_org_current_1',
338+
startsAtBlock: 1,
339+
endsAfterBlock: 50,
340+
feePerBlock: testPlan.fee,
341+
},
342+
],
343+
},
344+
],
345+
} as const;
305346

306347
it('shows no plans when user is signed in but has no subscription', async () => {
307348
const { wrapper, fixtures, props } = await createFixtures(f => {
@@ -581,6 +622,128 @@ describe('PricingTable - plans visibility', () => {
581622
});
582623
});
583624

625+
it('disables switching to an organization plan when the active org exceeds its seat limit', async () => {
626+
const { wrapper, fixtures, props } = await createFixtures(f => {
627+
f.withBilling();
628+
f.withOrganizations();
629+
f.withUser({
630+
email_addresses: ['test@clerk.com'],
631+
organization_memberships: [
632+
{
633+
name: 'Org1',
634+
permissions: ['org:sys_billing:manage'],
635+
members_count: 17,
636+
pending_invitations_count: 4,
637+
},
638+
],
639+
});
640+
});
641+
642+
props.setProps({ for: 'organization' } as any);
643+
644+
fixtures.clerk.billing.getStatements.mockRejectedValue();
645+
fixtures.clerk.organization.getPaymentMethods.mockRejectedValue();
646+
fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [seatLimitedOrganizationPlan as any], total_count: 1 });
647+
fixtures.clerk.billing.getSubscription.mockResolvedValue({
648+
id: 'sub_org_active',
649+
status: 'active',
650+
activeAt: new Date('2021-01-01'),
651+
createdAt: new Date('2021-01-01'),
652+
nextPayment: null,
653+
pastDueAt: null,
654+
updatedAt: null,
655+
subscriptionItems: [
656+
{
657+
id: 'si_org_active',
658+
plan: currentOrganizationPlan,
659+
createdAt: new Date('2021-01-01'),
660+
paymentMethodId: 'src_1',
661+
pastDueAt: null,
662+
canceledAt: null,
663+
periodStart: new Date('2021-01-01'),
664+
periodEnd: new Date('2021-01-31'),
665+
planPeriod: 'month' as const,
666+
status: 'active' as const,
667+
isFreeTrial: false,
668+
cancel: vi.fn(),
669+
pathRoot: '',
670+
reload: vi.fn(),
671+
},
672+
],
673+
pathRoot: '',
674+
reload: vi.fn(),
675+
});
676+
677+
const { getByRole } = render(<PricingTable />, { wrapper });
678+
679+
await waitFor(() => {
680+
expect(getByRole('heading', { name: 'Organization Plan' })).toBeVisible();
681+
});
682+
683+
expect(getByRole('button', { name: 'Switch to this plan' })).toBeDisabled();
684+
});
685+
686+
it('keeps switching enabled when the active org is exactly at the plan seat limit', async () => {
687+
const { wrapper, fixtures, props } = await createFixtures(f => {
688+
f.withBilling();
689+
f.withOrganizations();
690+
f.withUser({
691+
email_addresses: ['test@clerk.com'],
692+
organization_memberships: [
693+
{
694+
name: 'Org1',
695+
permissions: ['org:sys_billing:manage'],
696+
members_count: 17,
697+
pending_invitations_count: 3,
698+
},
699+
],
700+
});
701+
});
702+
703+
props.setProps({ for: 'organization' } as any);
704+
705+
fixtures.clerk.billing.getStatements.mockRejectedValue();
706+
fixtures.clerk.organization.getPaymentMethods.mockRejectedValue();
707+
fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [seatLimitedOrganizationPlan as any], total_count: 1 });
708+
fixtures.clerk.billing.getSubscription.mockResolvedValue({
709+
id: 'sub_org_active',
710+
status: 'active',
711+
activeAt: new Date('2021-01-01'),
712+
createdAt: new Date('2021-01-01'),
713+
nextPayment: null,
714+
pastDueAt: null,
715+
updatedAt: null,
716+
subscriptionItems: [
717+
{
718+
id: 'si_org_active',
719+
plan: currentOrganizationPlan,
720+
createdAt: new Date('2021-01-01'),
721+
paymentMethodId: 'src_1',
722+
pastDueAt: null,
723+
canceledAt: null,
724+
periodStart: new Date('2021-01-01'),
725+
periodEnd: new Date('2021-01-31'),
726+
planPeriod: 'month' as const,
727+
status: 'active' as const,
728+
isFreeTrial: false,
729+
cancel: vi.fn(),
730+
pathRoot: '',
731+
reload: vi.fn(),
732+
},
733+
],
734+
pathRoot: '',
735+
reload: vi.fn(),
736+
});
737+
738+
const { getByRole } = render(<PricingTable />, { wrapper });
739+
740+
await waitFor(() => {
741+
expect(getByRole('heading', { name: 'Organization Plan' })).toBeVisible();
742+
});
743+
744+
expect(getByRole('button', { name: 'Switch to this plan' })).toBeEnabled();
745+
});
746+
584747
it('fetches user plans and renders when using for: user', async () => {
585748
const { wrapper, fixtures, props } = await createFixtures(f => {
586749
f.withUser({ email_addresses: ['test@clerk.com'] });

packages/ui/src/contexts/components/Plans.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import type {
1212
BillingPlanResource,
1313
BillingSubscriptionItemResource,
1414
BillingSubscriptionPlanPeriod,
15+
OrganizationResource,
1516
} from '@clerk/shared/types';
1617
import { useCallback, useMemo } from 'react';
1718

1819
import { useProtect } from '@/ui/common/Gate';
20+
import { organizationExceedsPlanSeatLimit } from '@/ui/utils/billingPlanSeats';
1921
import { getClosestProfileScrollBox } from '@/ui/utils/getClosestProfileScrollBox';
2022

2123
import type { Appearance } from '../../internal/appearance';
@@ -196,12 +198,14 @@ export const usePlansContext = () => {
196198
const buttonPropsForPlan = useCallback(
197199
({
198200
plan,
201+
organization,
199202
// TODO(@COMMERCE): This needs to be removed.
200203
subscription: sub,
201204
isCompact = false,
202205
selectedPlanPeriod = 'annual',
203206
}: {
204-
plan?: BillingPlanResource;
207+
plan: BillingPlanResource;
208+
organization?: OrganizationResource | null;
205209
subscription?: BillingSubscriptionItemResource;
206210
isCompact?: boolean;
207211
selectedPlanPeriod?: BillingSubscriptionPlanPeriod;
@@ -214,6 +218,8 @@ export const usePlansContext = () => {
214218
} => {
215219
const subscription =
216220
sub ?? (plan ? activeOrUpcomingSubscriptionWithPlanPeriod(plan, selectedPlanPeriod) : undefined);
221+
const exceedsPlanSeatLimit =
222+
subscriberType === 'organization' && !!organization && organizationExceedsPlanSeatLimit(plan, organization);
217223
let _selectedPlanPeriod = selectedPlanPeriod;
218224
const isEligibleForSwitchToAnnual = Boolean(plan?.annualMonthlyFee);
219225

@@ -279,11 +285,17 @@ export const usePlansContext = () => {
279285
localizationKey: freeTrialOr(getLocalizationKey()),
280286
variant: isCompact ? 'bordered' : 'solid',
281287
colorScheme: isCompact ? 'secondary' : 'primary',
282-
isDisabled: !canManageBilling,
283-
disabled: !canManageBilling,
288+
isDisabled: !canManageBilling || exceedsPlanSeatLimit,
289+
disabled: !canManageBilling || exceedsPlanSeatLimit,
284290
};
285291
},
286-
[activeOrUpcomingSubscriptionWithPlanPeriod, canManageBilling, subscriptionItems, topLevelSubscription],
292+
[
293+
activeOrUpcomingSubscriptionWithPlanPeriod,
294+
canManageBilling,
295+
subscriberType,
296+
subscriptionItems,
297+
topLevelSubscription,
298+
],
287299
);
288300

289301
const captionForSubscription = useCallback((subscription: BillingSubscriptionItemResource) => {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { BillingPlanResource, BillingPlanUnitPrice, OrganizationResource } from '@clerk/shared/types';
2+
3+
/**
4+
* Given a plan, return the unit price for seats.
5+
*/
6+
export const getSeatUnitPrice = (plan: BillingPlanResource): BillingPlanUnitPrice | null => {
7+
if (!plan.unitPrices?.length) {
8+
return null;
9+
}
10+
11+
const seatUnitPrice = plan.unitPrices.find(unitPrice => unitPrice.name === 'seats');
12+
13+
if (seatUnitPrice) {
14+
return seatUnitPrice;
15+
}
16+
17+
return null;
18+
};
19+
20+
/**
21+
* Given a plan, return the seat limit for the plan, or undefined if the plan does not have a seat limit.
22+
*/
23+
export const getPlanSeatLimit = (plan: BillingPlanResource): number | null | undefined => {
24+
const seatUnitPrice = getSeatUnitPrice(plan);
25+
26+
if (!seatUnitPrice?.tiers.length) {
27+
return undefined;
28+
}
29+
30+
return seatUnitPrice.tiers[seatUnitPrice.tiers.length - 1]?.endsAfterBlock;
31+
};
32+
33+
/**
34+
* Given a plan and an organization, return true if the organization exceeds the seat limit for the plan.
35+
*/
36+
export const organizationExceedsPlanSeatLimit = (
37+
plan: BillingPlanResource,
38+
organization: OrganizationResource,
39+
): boolean => {
40+
const seatLimit = getPlanSeatLimit(plan);
41+
42+
if (seatLimit === undefined || seatLimit === null) {
43+
return false;
44+
}
45+
46+
return organization.membersCount + organization.pendingInvitationsCount > seatLimit;
47+
};

0 commit comments

Comments
 (0)