diff --git a/packages/clerk-js/src/core/modules/billing/namespace.ts b/packages/clerk-js/src/core/modules/billing/namespace.ts index f055a531d5c..ea628adb418 100644 --- a/packages/clerk-js/src/core/modules/billing/namespace.ts +++ b/packages/clerk-js/src/core/modules/billing/namespace.ts @@ -36,8 +36,8 @@ export class Billing implements BillingNamespace { } getPlans = async (params?: GetPlansParams): Promise> => { - const { for: forParam, ...safeParams } = params || {}; - const searchParams = { ...safeParams, payer_type: forParam === 'organization' ? 'org' : 'user' }; + const { for: forParam, org_id, min_seats, ...safeParams } = params || {}; + const searchParams = { ...safeParams, payer_type: forParam === 'organization' ? 'org' : 'user', org_id, min_seats }; return await BaseResource._fetch({ path: `${Billing.#pathRoot}/plans`, method: 'GET', diff --git a/packages/clerk-js/src/core/modules/checkout/instance.ts b/packages/clerk-js/src/core/modules/checkout/instance.ts index 85224308462..aab5c213158 100644 --- a/packages/clerk-js/src/core/modules/checkout/instance.ts +++ b/packages/clerk-js/src/core/modules/checkout/instance.ts @@ -9,9 +9,16 @@ type CheckoutKey = string & { readonly __tag: 'CheckoutKey' }; /** * Generate cache key for checkout instance */ -function cacheKey(options: { userId: string; orgId?: string; planId: string; planPeriod: string }): CheckoutKey { - const { userId, orgId, planId, planPeriod } = options; - return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}` as CheckoutKey; +function cacheKey(options: { + userId: string; + orgId?: string; + planId: string; + planPeriod: string; + seatsQuantity?: number; + priceId?: string; +}): CheckoutKey { + const { userId, orgId, planId, planPeriod, seatsQuantity, priceId } = options; + return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}-${seatsQuantity}-${priceId}` as CheckoutKey; } /** @@ -26,7 +33,7 @@ const CheckoutSignalCache = new Map< * Create a checkout instance with the given options */ function createCheckoutInstance(clerk: Clerk, options: __experimental_CheckoutOptions): CheckoutSignalValue { - const { for: forOrganization, planId, planPeriod } = options; + const { for: forOrganization, planId, planPeriod, seatsQuantity, priceId } = options; if (clerk.user === null) { throw new Error('Clerk: User is not authenticated'); @@ -43,6 +50,8 @@ function createCheckoutInstance(clerk: Clerk, options: __experimental_CheckoutOp orgId: forOrganization === 'organization' ? clerk.organization?.id : undefined, planId, planPeriod, + seatsQuantity, + priceId, }); const checkoutInstance = CheckoutSignalCache.get(checkoutKey); @@ -56,6 +65,8 @@ function createCheckoutInstance(clerk: Clerk, options: __experimental_CheckoutOp ...(forOrganization === 'organization' ? { orgId: clerk.organization?.id } : {}), planId, planPeriod, + seatsQuantity, + priceId, }); CheckoutSignalCache.set(checkoutKey, { resource: checkout, signals }); diff --git a/packages/clerk-js/src/core/resources/BillingPlan.ts b/packages/clerk-js/src/core/resources/BillingPlan.ts index fb5ab1b0ad6..d25fc25d642 100644 --- a/packages/clerk-js/src/core/resources/BillingPlan.ts +++ b/packages/clerk-js/src/core/resources/BillingPlan.ts @@ -2,11 +2,12 @@ import type { BillingMoneyAmount, BillingPayerResourceType, BillingPlanJSON, + BillingPlanPrice, BillingPlanResource, BillingPlanUnitPrice, } from '@clerk/shared/types'; -import { billingMoneyAmountFromJSON } from '@/utils/billing'; +import { billingMoneyAmountFromJSON, billingUnitPriceFromJSON } from '@/utils/billing'; import { BaseResource, Feature } from './internal'; @@ -26,6 +27,7 @@ export class BillingPlan extends BaseResource implements BillingPlanResource { avatarUrl: string | null = null; features!: Feature[]; unitPrices?: BillingPlanUnitPrice[]; + availablePrices?: BillingPlanPrice[]; freeTrialDays!: number | null; freeTrialEnabled!: boolean; @@ -55,15 +57,13 @@ export class BillingPlan extends BaseResource implements BillingPlanResource { this.freeTrialDays = this.withDefault(data.free_trial_days, null); this.freeTrialEnabled = this.withDefault(data.free_trial_enabled, false); this.features = (data.features || []).map(feature => new Feature(feature)); - this.unitPrices = data.unit_prices?.map(unitPrice => ({ - name: unitPrice.name, - blockSize: unitPrice.block_size, - tiers: unitPrice.tiers.map(tier => ({ - id: tier.id, - startsAtBlock: tier.starts_at_block, - endsAfterBlock: tier.ends_after_block, - feePerBlock: billingMoneyAmountFromJSON(tier.fee_per_block), - })), + this.unitPrices = data.unit_prices?.map(billingUnitPriceFromJSON); + this.availablePrices = data.available_prices?.map(price => ({ + id: price.id, + fee: price.fee ? billingMoneyAmountFromJSON(price.fee) : null, + annualMonthlyFee: price.annual_monthly_fee ? billingMoneyAmountFromJSON(price.annual_monthly_fee) : null, + isDefault: price.is_default, + unitPrices: data.unit_prices?.map(billingUnitPriceFromJSON), })); return this; diff --git a/packages/clerk-js/src/core/resources/BillingSubscription.ts b/packages/clerk-js/src/core/resources/BillingSubscription.ts index 239194daacd..131f3d3e9ad 100644 --- a/packages/clerk-js/src/core/resources/BillingSubscription.ts +++ b/packages/clerk-js/src/core/resources/BillingSubscription.ts @@ -66,6 +66,7 @@ export class BillingSubscriptionItem extends BaseResource implements BillingSubs id!: string; plan!: BillingPlan; planPeriod!: BillingSubscriptionPlanPeriod; + priceId!: string; status!: BillingSubscriptionStatus; createdAt!: Date; periodStart!: Date; @@ -94,6 +95,7 @@ export class BillingSubscriptionItem extends BaseResource implements BillingSubs this.id = data.id; this.plan = new BillingPlan(data.plan); this.planPeriod = data.plan_period; + this.priceId = data.price_id; this.status = data.status; this.createdAt = unixEpochToDate(data.created_at); diff --git a/packages/clerk-js/src/utils/billing.ts b/packages/clerk-js/src/utils/billing.ts index e994843361f..9e337f94d9e 100644 --- a/packages/clerk-js/src/utils/billing.ts +++ b/packages/clerk-js/src/utils/billing.ts @@ -7,6 +7,7 @@ import type { BillingMoneyAmountJSON, BillingPerUnitTotal, BillingPerUnitTotalJSON, + BillingPlanUnitPriceJSON, BillingStatementTotals, BillingStatementTotalsJSON, } from '@clerk/shared/types'; @@ -32,6 +33,17 @@ const billingPerUnitTotalsFromJSON = (data: BillingPerUnitTotalJSON[]): BillingP })); }; +export const billingUnitPriceFromJSON = (unitPrice: BillingPlanUnitPriceJSON) => ({ + name: unitPrice.name, + blockSize: unitPrice.block_size, + tiers: unitPrice.tiers.map(tier => ({ + id: tier.id, + startsAtBlock: tier.starts_at_block, + endsAfterBlock: tier.ends_after_block, + feePerBlock: billingMoneyAmountFromJSON(tier.fee_per_block), + })), +}); + export const billingCreditsFromJSON = (data: BillingCreditsJSON): BillingCredits => { return { proration: data.proration diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index be7dcff8482..de27dd6ef5a 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1204,6 +1204,10 @@ export const enUS: LocalizationResource = { form_username_invalid_length: 'Your username must be between {{min_length}} and {{max_length}} characters long.', form_username_needs_non_number_char: 'Your username must contain at least one non-numeric character.', identification_deletion_failed: undefined, + insufficient_seats_change_plan: + 'Your organization does not have enough seats to invite the desired number of members. Please change to a plan that supports the number of members you are attempting to invite.', + insufficient_seats_contact_support: + 'Your organization does not have enough seats to invite the desired number of members. Please contact support.', not_allowed_access: undefined, organization_domain_blocked: undefined, organization_domain_common: undefined, diff --git a/packages/react/src/components/CheckoutButton.tsx b/packages/react/src/components/CheckoutButton.tsx index a3ef2b3108b..22aac84270f 100644 --- a/packages/react/src/components/CheckoutButton.tsx +++ b/packages/react/src/components/CheckoutButton.tsx @@ -50,6 +50,8 @@ export const CheckoutButton = withClerk( const { planId, planPeriod, + seatsQuantity, + priceId, for: _for, onSubscriptionComplete, newSubscriptionRedirectUrl, @@ -84,6 +86,8 @@ export const CheckoutButton = withClerk( return clerk.__internal_openCheckout({ planId, planPeriod, + seatsQuantity, + priceId, for: _for, onSubscriptionComplete, newSubscriptionRedirectUrl, diff --git a/packages/react/src/components/__tests__/CheckoutButton.test.tsx b/packages/react/src/components/__tests__/CheckoutButton.test.tsx index fe8d7f68fb3..206880cd368 100644 --- a/packages/react/src/components/__tests__/CheckoutButton.test.tsx +++ b/packages/react/src/components/__tests__/CheckoutButton.test.tsx @@ -101,6 +101,7 @@ describe('CheckoutButton', () => { const props = { planId: 'test_plan', planPeriod: 'month' as const, + seatsQuantity: 7, onSubscriptionComplete: vi.fn(), newSubscriptionRedirectUrl: '/success', checkoutProps: { @@ -121,6 +122,7 @@ describe('CheckoutButton', () => { onSubscriptionComplete: props.onSubscriptionComplete, newSubscriptionRedirectUrl: props.newSubscriptionRedirectUrl, planPeriod: props.planPeriod, + seatsQuantity: props.seatsQuantity, }), ); }); diff --git a/packages/shared/src/errors/clerkApiError.ts b/packages/shared/src/errors/clerkApiError.ts index 4daf5fa329b..f8c26fa2b17 100644 --- a/packages/shared/src/errors/clerkApiError.ts +++ b/packages/shared/src/errors/clerkApiError.ts @@ -26,6 +26,8 @@ export class ClerkAPIError implements Cler zxcvbn: json.meta?.zxcvbn, plan: json.meta?.plan, isPlanUpgradePossible: json.meta?.is_plan_upgrade_possible, + seatsQuantityToAdd: json.meta?.seats_quantity_to_add, + seatsQuantity: json.meta?.seats_quantity, } as unknown as Meta, }; this.code = parsedError.code; diff --git a/packages/shared/src/errors/parseError.ts b/packages/shared/src/errors/parseError.ts index 4c2f8c98baa..e29ebf2e864 100644 --- a/packages/shared/src/errors/parseError.ts +++ b/packages/shared/src/errors/parseError.ts @@ -39,6 +39,8 @@ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON { zxcvbn: error?.meta?.zxcvbn, plan: error?.meta?.plan, is_plan_upgrade_possible: error?.meta?.isPlanUpgradePossible, + seats_quantity_to_add: error?.meta?.seatsQuantityToAdd, + seats_quantity: error?.meta?.seatsQuantity, }, }; } diff --git a/packages/shared/src/react/__tests__/payment-element.test.tsx b/packages/shared/src/react/__tests__/payment-element.test.tsx index 3efd256e3ec..dd4f5a975ba 100644 --- a/packages/shared/src/react/__tests__/payment-element.test.tsx +++ b/packages/shared/src/react/__tests__/payment-element.test.tsx @@ -127,6 +127,7 @@ describe('PaymentElement Localization', () => { grandTotal: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' }, taxTotal: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' }, totalDueNow: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' }, + totalDuePerPeriod: { amount: 1000, amountFormatted: '$10.00', currency: 'usd', currencySymbol: '$' }, totalDueAfterFreeTrial: null, credit: { amount: 0, amountFormatted: '$0.00', currency: 'usd', currencySymbol: '$' }, credits: { diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index 06dd7765dd1..a432ea9e09c 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -73,6 +73,8 @@ export type UseCheckoutOptions = { * The ID of the Subscription Plan to check out (e.g. `cplan_xxx`). */ planId: string; + seatsQuantity?: number; + priceId?: string; }; const [CheckoutContext, useCheckoutContext] = createContextAndHook('CheckoutContext'); diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 1fd72e2f5fc..1490c3d3318 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -17,7 +17,7 @@ type UseCheckoutParams = Parameters[0]; */ export const useCheckout = (options?: UseCheckoutParams): CheckoutSignalValue => { const contextOptions = useCheckoutContext(); - const { for: forOrganization, planId, planPeriod } = options || contextOptions; + const { for: forOrganization, planId, planPeriod, seatsQuantity, priceId } = options || contextOptions; const organization = useOrganizationBase(); const { isLoaded, user } = useUser(); const clerk = useClerkInstanceContext(); @@ -33,8 +33,8 @@ export const useCheckout = (options?: UseCheckoutParams): CheckoutSignalValue => } const signal = useCallback(() => { - return clerk.__experimental_checkout({ planId, planPeriod, for: forOrganization }); - }, [user?.id, organization?.id, planId, planPeriod, forOrganization]); + return clerk.__experimental_checkout({ planId, planPeriod, for: forOrganization, seatsQuantity, priceId }); + }, [user?.id, organization?.id, planId, planPeriod, forOrganization, seatsQuantity, priceId]); const subscribe = useCallback( (callback: () => void) => { diff --git a/packages/shared/src/types/billing.ts b/packages/shared/src/types/billing.ts index 151f72db0d5..cd9f3623647 100644 --- a/packages/shared/src/types/billing.ts +++ b/packages/shared/src/types/billing.ts @@ -140,6 +140,8 @@ export type GetPlansParams = ClerkPaginationParams<{ * The type of payer for the Plans. */ for?: ForPayerType; + org_id?: string; + min_seats?: number; }>; /** @@ -210,6 +212,7 @@ export interface BillingPlanResource extends ClerkResource { * Per-unit pricing tiers for this Plan (for example, seats). */ unitPrices?: BillingPlanUnitPrice[]; + availablePrices?: BillingPlanPrice[]; /** * The number of days of the free trial for the Plan. `null` if the Plan does not have a free trial. */ @@ -276,6 +279,14 @@ export interface BillingPlanUnitPrice { tiers: BillingPlanUnitPriceTier[]; } +export interface BillingPlanPrice { + id: string; + fee: BillingMoneyAmount | null; + annualMonthlyFee: BillingMoneyAmount | null; + isDefault: boolean; + unitPrices?: BillingPlanUnitPrice[]; +} + /** * The `BillingPerUnitTotalTier` type represents the cost breakdown for a single tier in checkout totals. * @@ -656,6 +667,7 @@ export interface BillingSubscriptionItemResource extends ClerkResource { * The billing period for the subscription item. */ planPeriod: BillingSubscriptionPlanPeriod; + priceId: string; /** * The status of the subscription item. */ @@ -893,6 +905,8 @@ export type CreateCheckoutParams = WithOptionalOrgType<{ * The billing period for the Plan. */ planPeriod: BillingSubscriptionPlanPeriod; + seatsQuantity?: number; + priceId?: string; }>; /** diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 88613446bcb..f70434c66b7 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -79,6 +79,8 @@ export type __experimental_CheckoutOptions = { for?: ForPayerType; planPeriod: BillingSubscriptionPlanPeriod; planId: string; + seatsQuantity?: number; + priceId?: string; }; export type CheckoutErrors = { @@ -2205,6 +2207,8 @@ export type __internal_CheckoutProps = { appearance?: ClerkAppearanceTheme; planId?: string; planPeriod?: BillingSubscriptionPlanPeriod; + seatsQuantity?: number; + priceId?: string; for?: ForPayerType; onSubscriptionComplete?: () => void; portalId?: string; @@ -2225,6 +2229,8 @@ export type __experimental_CheckoutButtonProps = { planId: string; planPeriod?: BillingSubscriptionPlanPeriod; for?: ForPayerType; + seatsQuantity?: number; + priceId?: string; onSubscriptionComplete?: () => void; checkoutProps?: { appearance?: ClerkAppearanceTheme; diff --git a/packages/shared/src/types/errors.ts b/packages/shared/src/types/errors.ts index ff8e17be7ac..60531f065b4 100644 --- a/packages/shared/src/types/errors.ts +++ b/packages/shared/src/types/errors.ts @@ -21,6 +21,8 @@ export interface ClerkAPIErrorJSON { name: string; }; is_plan_upgrade_possible?: boolean; + seats_quantity_to_add?: number; + seats_quantity?: number; }; } @@ -63,6 +65,8 @@ export interface ClerkAPIError { name: string; }; isPlanUpgradePossible?: boolean; + seatsQuantityToAdd?: number; + seatsQuantity?: number; }; } diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 4f26f9b7c8c..2074d2e1900 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -663,6 +663,14 @@ export interface BillingPerUnitTotalJSON { tiers: BillingPerUnitTotalTierJSON[]; } +export interface BillingPriceJSON extends ClerkResourceJSON { + object: 'commerce_price'; + fee: BillingMoneyAmountJSON | null; + annual_monthly_fee: BillingMoneyAmountJSON | null; + is_default: boolean; + unit_prices?: BillingPlanUnitPriceJSON[]; +} + /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ @@ -688,6 +696,7 @@ export interface BillingPlanJSON extends ClerkResourceJSON { * Per-unit pricing tiers for this plan (for example, seats). */ unit_prices?: BillingPlanUnitPriceJSON[]; + available_prices?: BillingPriceJSON[]; } /** @@ -774,6 +783,7 @@ export interface BillingSubscriptionItemJSON extends ClerkResourceJSON { credits?: BillingCreditsJSON; plan: BillingPlanJSON; plan_period: BillingSubscriptionPlanPeriod; + price_id: string; status: BillingSubscriptionStatus; created_at: number; period_start: number; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 3bb00ce6831..a6901ad686a 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1695,4 +1695,6 @@ type UnstableErrors = WithParamName<{ organization_membership_quota_exceeded: LocalizationValue; organization_not_found_or_unauthorized: LocalizationValue; organization_not_found_or_unauthorized_with_create_organization_disabled: LocalizationValue; + insufficient_seats_contact_support: LocalizationValue; + insufficient_seats_change_plan: LocalizationValue; }>; diff --git a/packages/ui/src/components/Checkout/CheckoutPage.tsx b/packages/ui/src/components/Checkout/CheckoutPage.tsx index 220b1f036bd..6c7b267c4c5 100644 --- a/packages/ui/src/components/Checkout/CheckoutPage.tsx +++ b/packages/ui/src/components/Checkout/CheckoutPage.tsx @@ -11,12 +11,12 @@ const Initiator = () => { useEffect(() => { void checkout.start(); - }, []); + }, [checkout]); return null; }; const Root = ({ children }: { children: React.ReactNode }) => { - const { planId, planPeriod, for: _for } = useCheckoutContext(); + const { planId, planPeriod, for: _for, seatsQuantity, priceId } = useCheckoutContext(); return ( { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion planPeriod! } + seatsQuantity={seatsQuantity} + priceId={priceId} > {children} diff --git a/packages/ui/src/components/Checkout/__tests__/Checkout.test.tsx b/packages/ui/src/components/Checkout/__tests__/Checkout.test.tsx index 8414f0c9ea8..100a669ecc2 100644 --- a/packages/ui/src/components/Checkout/__tests__/Checkout.test.tsx +++ b/packages/ui/src/components/Checkout/__tests__/Checkout.test.tsx @@ -61,6 +61,39 @@ describe('Checkout', () => { }); }); + it('passes seatsQuantity to checkout initialization', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); + }); + + fixtures.clerk.billing.startCheckout.mockResolvedValue({} as any); + + render( + {}} + > + + , + { wrapper }, + ); + + await waitFor(() => { + expect(fixtures.clerk.billing.startCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + planId: 'plan_with_seats', + planPeriod: 'month', + seatsQuantity: 7, + }), + ); + }); + }); + it('renders drawer structure and localization correctly', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); @@ -345,6 +378,7 @@ describe('Checkout', () => { }, pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, totalDueNow: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, + totalDuePerPeriod: { amount: 2500, amountFormatted: '25.00', currency: 'USD', currencySymbol: '$' }, }, isImmediatePlanChange: true, planPeriod: 'month', @@ -442,6 +476,7 @@ describe('Checkout', () => { pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, totalDueAfterFreeTrial: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, totalDueNow: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, + totalDuePerPeriod: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, }, isImmediatePlanChange: true, planPeriod: 'month', @@ -526,6 +561,7 @@ describe('Checkout', () => { credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, totalDueNow: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, + totalDuePerPeriod: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, }, isImmediatePlanChange: true, planPeriod: 'month', @@ -606,6 +642,7 @@ describe('Checkout', () => { credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, totalDueNow: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, + totalDuePerPeriod: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, }, isImmediatePlanChange: true, planPeriod: 'month', @@ -699,6 +736,7 @@ describe('Checkout', () => { credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, totalDueNow: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, + totalDuePerPeriod: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, }, isImmediatePlanChange: true, planPeriod: 'month', @@ -825,6 +863,7 @@ describe('Checkout', () => { credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, totalDueNow: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, + totalDuePerPeriod: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, }, isImmediatePlanChange: true, planPeriod: 'month', @@ -965,6 +1004,7 @@ describe('Checkout', () => { credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, totalDueNow: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, + totalDuePerPeriod: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, }, isImmediatePlanChange: true, planPeriod: 'month', @@ -1092,6 +1132,7 @@ describe('Checkout', () => { credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, totalDueNow: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, + totalDuePerPeriod: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, }, isImmediatePlanChange: true, planPeriod: 'month', @@ -1187,6 +1228,7 @@ describe('Checkout', () => { credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, totalDueNow: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, + totalDuePerPeriod: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, }, isImmediatePlanChange: true, planPeriod: 'month', @@ -1278,6 +1320,7 @@ describe('Checkout', () => { credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, totalDueNow: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, + totalDuePerPeriod: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, }, isImmediatePlanChange: true, planPeriod: 'month', @@ -1383,6 +1426,7 @@ describe('Checkout', () => { credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, totalDueNow: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, + totalDuePerPeriod: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, }, isImmediatePlanChange: true, planPeriod: 'month', @@ -1515,6 +1559,7 @@ describe('Checkout', () => { credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, totalDueNow: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' }, + totalDuePerPeriod: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' }, }, isImmediatePlanChange: false, planPeriod: 'month', diff --git a/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx b/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx index 3a8ecc68b8d..926729adc62 100644 --- a/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx +++ b/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx @@ -1,5 +1,5 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; -import { useOrganization } from '@clerk/shared/react'; +import { useClerk, useOrganization } from '@clerk/shared/react'; import type { ClerkAPIError } from '@clerk/shared/types'; import type { FormEvent } from 'react'; import { useEffect, useState } from 'react'; @@ -9,10 +9,11 @@ import { Form } from '@/ui/elements/Form'; import { FormButtonContainer } from '@/ui/elements/FormButtons'; import { TagInput } from '@/ui/elements/TagInput'; import { handleError } from '@/ui/utils/errorHandler'; +import { getClosestProfileScrollBoxFromElement } from '@/ui/utils/getClosestProfileScrollBox'; import { createListFormat } from '@/ui/utils/passwordUtils'; import { useFormControl } from '@/ui/utils/useFormControl'; -import { useEnvironment } from '../../contexts'; +import { useEnvironment, usePlansContext, useSubscription } from '../../contexts'; import { Flex } from '../../customizables'; import { useFetchRoles } from '../../hooks/useFetchRoles'; import type { LocalizationKey } from '../../localization'; @@ -31,12 +32,15 @@ type InviteMembersFormProps = { export const InviteMembersForm = (props: InviteMembersFormProps) => { const { onSuccess, onReset, resetButtonLabel } = props; + const clerk = useClerk(); const { organization, invitations } = useOrganization({ invitations: { pageSize: 10, keepPreviousData: true, }, }); + const { subscriptionItems } = useSubscription(); + const { handleSelectPlan } = usePlansContext(); const card = useCardState(); const { t, locale } = useLocalizations(); const [isValidUnsubmittedEmail, setIsValidUnsubmittedEmail] = useState(false); @@ -75,72 +79,115 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => { const canSubmit = (!!emailAddressField.value.length || isValidUnsubmittedEmail) && !!roleField.value; - const onSubmit = (e: FormEvent) => { + const onSubmit = async (e: FormEvent) => { e.preventDefault(); if (!canSubmit) { return; } const submittedData = new FormData(e.currentTarget); - return organization - .inviteMembers({ + const portalRoot = getClosestProfileScrollBoxFromElement(e.currentTarget); + try { + await organization.inviteMembers({ emailAddresses: emailAddressField.value.split(','), role: submittedData.get('role') as string, - }) - .then(async () => { - await invitations?.revalidate?.(); - return onSuccess?.(); - }) - .catch(err => { - if (!isClerkAPIResponseError(err)) { + }); + + await invitations?.revalidate?.(); + onSuccess?.(); + } catch (err) { + if (!isClerkAPIResponseError(err)) { + if (err instanceof Error) { handleError(err, [], card.setError); return; } - removeInvalidEmails(err.errors[0]); - - switch (err.errors?.[0]?.code) { - case 'duplicate_record': { - const unlocalizedEmailsList = err.errors[0].meta?.emailAddresses || []; - card.setError( - t( - localizationKeys('organizationProfile.invitePage.detailsTitle__inviteFailed', { - // Create a localized list of email addresses - email_addresses: createListFormat(unlocalizedEmailsList, locale), - }), - ), + throw err; + } + + removeInvalidEmails(err.errors[0]); + + switch (err.errors?.[0]?.code) { + case 'duplicate_record': { + const unlocalizedEmailsList = err.errors[0].meta?.emailAddresses || []; + card.setError( + t( + localizationKeys('organizationProfile.invitePage.detailsTitle__inviteFailed', { + // Create a localized list of email addresses + email_addresses: createListFormat(unlocalizedEmailsList, locale), + }), + ), + ); + break; + } + case 'already_a_member_in_organization': { + /** + * Extracts email from the error message since it's not provided in the error response + */ + const longMessage = err.errors[0].longMessage ?? ''; + const email = longMessage.match(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/)?.[0]; + + handleError(err, [], err => + email + ? /** + * Fallbacks to original error message in case the email cannot be extracted + */ + card.setError( + t( + localizationKeys('unstable__errors.already_a_member_in_organization', { + email, + }), + ), + ) + : card.setError(err), + ); + + break; + } + case 'insufficient_seats': { + const { data: plans } = await clerk.billing.getPlans({ + for: 'organization', + org_id: organization.id, + min_seats: err.errors[0].meta?.seatsQuantity, + }); + + if (plans.length === 0) { + handleError(err, [], () => + card.setError(t(localizationKeys('unstable__errors.insufficient_seats_contact_support'))), ); break; } - case 'already_a_member_in_organization': { - /** - * Extracts email from the error message since it's not provided in the error response - */ - const longMessage = err.errors[0].longMessage ?? ''; - const email = longMessage.match(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/)?.[0]; - - handleError(err, [], err => - email - ? /** - * Fallbacks to original error message in case the email cannot be extracted - */ - card.setError( - t( - localizationKeys('unstable__errors.already_a_member_in_organization', { - email, - }), - ), - ) - : card.setError(err), - ); - break; - } - default: { - handleError(err, [], card.setError); + const activeSubscriptionItem = subscriptionItems.find(si => si.status === 'active'); + if (activeSubscriptionItem) { + const currentPlan = activeSubscriptionItem.plan; + const currentPlanAndPriceSupportsDesiredSeatQuantity = plans.some( + p => + p.id === currentPlan.id && + p.availablePrices?.some(price => price.id === activeSubscriptionItem.priceId), + ); + if (currentPlanAndPriceSupportsDesiredSeatQuantity) { + handleSelectPlan({ + mode: 'modal', + plan: currentPlan, + planPeriod: activeSubscriptionItem.planPeriod, + seatsQuantity: err.errors[0].meta?.seatsQuantity, + portalRoot, + }); + break; + } } + + handleError(err, [], () => + card.setError(t(localizationKeys('unstable__errors.insufficient_seats_change_plan'))), + ); + break; } - }); + default: { + handleError(err, [], card.setError); + } + } + } }; const removeInvalidEmails = (err: ClerkAPIError) => { diff --git a/packages/ui/src/components/OrganizationProfile/MembersActions.tsx b/packages/ui/src/components/OrganizationProfile/MembersActions.tsx index bf7824ef1af..c608eb031c7 100644 --- a/packages/ui/src/components/OrganizationProfile/MembersActions.tsx +++ b/packages/ui/src/components/OrganizationProfile/MembersActions.tsx @@ -2,8 +2,10 @@ import { useMemo, type ReactNode } from 'react'; import { useOrganization } from '@clerk/shared/react'; import { Animated } from '@/ui/elements/Animated'; import { Tooltip } from '@/ui/elements/Tooltip'; +import { isPlanWithPerSeatCosts } from '@/ui/utils/billingPlanSeats'; import { useProtect } from '../../common'; +import { useSubscription } from '../../contexts'; import { Button, descriptors, Flex, localizationKeys } from '../../customizables'; import { Action } from '../../elements/Action'; import { InviteMembersScreen } from './InviteMembersScreen'; @@ -15,19 +17,24 @@ type MembersActionsRowProps = { export const MembersActionsRow = ({ actionSlot }: MembersActionsRowProps) => { const canManageMemberships = useProtect({ permission: 'org:sys_memberships:manage' }); const { organization } = useOrganization(); + const { subscriptionItems } = useSubscription(); const isBelowLimit = useMemo(() => { if (!organization) { return false; } + if (subscriptionItems.length > 0 && isPlanWithPerSeatCosts(subscriptionItems[0].plan)) { + return true; + } + // A value of 0 means unlimited memberships, thus the organization is always below the limit if (organization.maxAllowedMemberships === 0) { return true; } return organization.membersCount + organization.pendingInvitationsCount < organization.maxAllowedMemberships; - }, [organization]); + }, [organization, subscriptionItems]); const inviteButton = (