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..39a682d14ec 100644 --- a/packages/ui/src/contexts/components/Plans.tsx +++ b/packages/ui/src/contexts/components/Plans.tsx @@ -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'; @@ -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; +};