diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 57803d65415..596f76ecf64 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -540,6 +540,7 @@ export const enUS: LocalizationResource = { detailsTitle__inviteFailed: 'The invitations could not be sent. There are already pending invitations for the following email addresses: {{email_addresses}}.', formButtonPrimary__continue: 'Send invitations', + formButtonPrimary__purchaseSeats: 'Purchase additional seats', selectDropdown__role: 'Select role', subtitle: 'Enter or paste one or more email addresses, separated by spaces or commas.', successMessage: 'Invitations successfully sent', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index afafe6dcc5d..aa8c45d5d80 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1115,6 +1115,7 @@ export type __internal_LocalizationResource = { successMessage: LocalizationValue; detailsTitle__inviteFailed: LocalizationValue<'email_addresses'>; formButtonPrimary__continue: LocalizationValue; + formButtonPrimary__purchaseSeats: LocalizationValue; selectDropdown__role: LocalizationValue; }; removeDomainPage: { diff --git a/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx b/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx index 3ec23fc463a..0a8dcc9682d 100644 --- a/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx +++ b/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx @@ -8,6 +8,11 @@ import { useCardState } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; import { FormButtonContainer } from '@/ui/elements/FormButtons'; import { TagInput } from '@/ui/elements/TagInput'; +import { + getPaidSeatsUnitTier, + getSeatUnitPrice, + organizationAndInvitationsExceedsPurchasedSeats, +} from '@/ui/utils/billingPlanSeats'; import { handleError } from '@/ui/utils/errorHandler'; import { getClosestProfileScrollBoxFromElement } from '@/ui/utils/getClosestProfileScrollBox'; import { createListFormat } from '@/ui/utils/passwordUtils'; @@ -39,7 +44,8 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => { keepPreviousData: true, }, }); - const { subscriptionItems } = useSubscription(); + const { data: subscription, subscriptionItems } = useSubscription(); + const activeSubscriptionItem = subscription?.subscriptionItems.find(si => si.status === 'active'); const { handleSelectPlan } = usePlansContext(); const card = useCardState(); const { t, locale } = useLocalizations(); @@ -78,6 +84,14 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => { } = emailAddressField; const canSubmit = (!!emailAddressField.value.length || isValidUnsubmittedEmail) && !!roleField.value; + const emailAddresses = emailAddressField.value.split(','); + + const seatUnitPrice = activeSubscriptionItem ? getSeatUnitPrice(activeSubscriptionItem.plan) : null; + const paidSeatsTier = seatUnitPrice ? getPaidSeatsUnitTier(seatUnitPrice) : null; + const isPerSeatCostPlan = !!paidSeatsTier; + const mustPurchaseSeats = + isPerSeatCostPlan && + organizationAndInvitationsExceedsPurchasedSeats(activeSubscriptionItem, organization, emailAddresses.length); const onSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -232,7 +246,11 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => { ({ amount, @@ -121,3 +133,67 @@ describe('summarizeSeatCharges', () => { expect(summary!.included).toBe(0); }); }); + +describe('getPaidSeatsUnitTier', () => { + test('returns the paid tier from a seats unit price with included seats', () => { + const paidTier = { + id: 'tier_paid', + startsAtBlock: 4, + endsAfterBlock: null, + feePerBlock: money(500), + }; + const unitPrice: BillingPlanUnitPrice = { + name: 'seats', + blockSize: 1, + tiers: [ + { + id: 'tier_included', + startsAtBlock: 1, + endsAfterBlock: 3, + feePerBlock: money(0), + }, + paidTier, + ], + }; + + expect(getPaidSeatsUnitTier(unitPrice)).toBe(paidTier); + }); +}); + +describe('organizationAndInvitationsExceedsPurchasedSeats', () => { + test('returns true when members, pending invitations, and invitations exceed seats entitlements', () => { + const subscriptionItem = { + seats: { quantity: 4 }, + } as BillingSubscriptionItemResource; + const organization = { + membersCount: 2, + pendingInvitationsCount: 1, + } as OrganizationResource; + + expect(organizationAndInvitationsExceedsPurchasedSeats(subscriptionItem, organization, 2)).toBe(true); + }); + + test('returns false when members, pending invitations, and invitations equal seats entitlements', () => { + const subscriptionItem = { + seats: { quantity: 5 }, + } as BillingSubscriptionItemResource; + const organization = { + membersCount: 2, + pendingInvitationsCount: 1, + } as OrganizationResource; + + expect(organizationAndInvitationsExceedsPurchasedSeats(subscriptionItem, organization, 2)).toBe(false); + }); + + test('returns false when members, pending invitations, and invitations are below seats entitlements', () => { + const subscriptionItem = { + seats: { quantity: 10 }, + } as BillingSubscriptionItemResource; + const organization = { + membersCount: 2, + pendingInvitationsCount: 1, + } as OrganizationResource; + + expect(organizationAndInvitationsExceedsPurchasedSeats(subscriptionItem, organization, 2)).toBe(false); + }); +}); diff --git a/packages/ui/src/utils/billingPlanSeats.ts b/packages/ui/src/utils/billingPlanSeats.ts index 9c86e016a02..3635e60a851 100644 --- a/packages/ui/src/utils/billingPlanSeats.ts +++ b/packages/ui/src/utils/billingPlanSeats.ts @@ -4,6 +4,8 @@ import type { BillingPerUnitTotalTier, BillingPlanResource, BillingPlanUnitPrice, + BillingPlanUnitPriceTier, + BillingSubscriptionItemResource, OrganizationResource, } from '@clerk/shared/types'; @@ -135,6 +137,26 @@ export const getIncludedSeatsUnitTotalTier = ( return null; }; +export const getPaidSeatsUnitTier = (unitPrice: BillingPlanUnitPrice | null): BillingPlanUnitPriceTier | null => { + if (!unitPrice) { + return null; + } + + if (unitPrice.tiers.length === 1 && unitPrice.tiers[0].feePerBlock.amount > 0) { + return unitPrice.tiers[0]; + } + + if ( + unitPrice.tiers.length === 2 && + unitPrice.tiers[0].feePerBlock.amount === 0 && + unitPrice.tiers[1].feePerBlock.amount > 0 + ) { + return unitPrice.tiers[1]; + } + + return null; +}; + /** * Given a plan, return the seat limit for the plan in seats (not blocks), or `null` if seats are * unlimited, or `undefined` if the plan has no seat-based pricing. @@ -166,6 +188,21 @@ export const organizationExceedsPlanSeatLimit = ( return organization.membersCount + organization.pendingInvitationsCount > seatLimit; }; +export const organizationAndInvitationsExceedsPurchasedSeats = ( + subscriptionItem: BillingSubscriptionItemResource | undefined, + organization: OrganizationResource, + invitationsCount: number, +): boolean => { + if (!subscriptionItem || !subscriptionItem.seats || !subscriptionItem.seats.quantity) { + return false; + } + + return ( + organization.membersCount + organization.pendingInvitationsCount + invitationsCount > + subscriptionItem.seats.quantity + ); +}; + export const isPlanWithPerSeatCosts = (plan: BillingPlanResource): boolean => { const seatUnitPrice = getSeatUnitPrice(plan);