Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1223,6 +1223,7 @@ export type __internal_LocalizationResource = {
title: LocalizationValue;
alerts: {
noPermissionsToManageBilling: LocalizationValue;
planMembershipLimitExceeded: LocalizationValue<'count' | 'limit'>;
};
};
apiKeysPage: {
Expand Down
29 changes: 21 additions & 8 deletions packages/ui/src/components/PricingTable/PricingTableDefault.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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);
}}
/>
</Tooltip.Trigger>
{isSignedIn && !canManageBilling && (
<Tooltip.Content
text={localizationKeys('organizationProfile.plansPage.alerts.noPermissionsToManageBilling')}
/>
)}
{footerButtonTooltipText ? <Tooltip.Content text={footerButtonTooltipText} /> : null}
</Tooltip.Root>
)}
</Box>
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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(<PricingTable />, { 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(<PricingTable />, { 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'] });
Expand Down
20 changes: 16 additions & 4 deletions packages/ui/src/contexts/components/Plans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import type {
BillingPlanResource,
BillingSubscriptionItemResource,
BillingSubscriptionPlanPeriod,
OrganizationResource,
} from '@clerk/shared/types';
import { useCallback, useMemo } from 'react';

import { useProtect } from '@/ui/common/Gate';
import { organizationExceedsPlanSeatLimit } from '@/ui/utils/billingPlanSeats';
import { getClosestProfileScrollBox } from '@/ui/utils/getClosestProfileScrollBox';

import type { Appearance } from '../../internal/appearance';
Expand Down Expand Up @@ -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;
Expand All @@ -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);

Expand Down Expand Up @@ -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) => {
Expand Down
47 changes: 47 additions & 0 deletions packages/ui/src/utils/billingPlanSeats.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading