diff --git a/.changeset/billing-seat-tier-rows-payment-attempt.md b/.changeset/billing-seat-tier-rows-payment-attempt.md new file mode 100644 index 00000000000..a48ab47b1ed --- /dev/null +++ b/.changeset/billing-seat-tier-rows-payment-attempt.md @@ -0,0 +1,7 @@ +--- +'@clerk/shared': minor +'@clerk/clerk-js': minor +'@clerk/ui': minor +--- + +Surface seat-based billing details on payment attempts. The payment attempt resource now exposes a `totals` field (`BillingPaymentTotals`) carrying optional `baseFee` and `perUnitTotals` breakdowns. The payment-attempt detail page renders a "Seats" line (`{quantity} × {feePerBlock}`, or the tier total for unlimited tiers) between the plan title and subtotal when the subscription item is seat-billed. diff --git a/.changeset/good-ads-greet.md b/.changeset/good-ads-greet.md new file mode 100644 index 00000000000..06dfb32119a --- /dev/null +++ b/.changeset/good-ads-greet.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Add support for rendering per-seat costs in checkout diff --git a/.changeset/khaki-hairs-punch.md b/.changeset/khaki-hairs-punch.md new file mode 100644 index 00000000000..7b4e9fe5ebc --- /dev/null +++ b/.changeset/khaki-hairs-punch.md @@ -0,0 +1,8 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Add support for total due per period to checkout 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/BillingPayment.ts b/packages/clerk-js/src/core/resources/BillingPayment.ts index 890f6362a65..307093e57b4 100644 --- a/packages/clerk-js/src/core/resources/BillingPayment.ts +++ b/packages/clerk-js/src/core/resources/BillingPayment.ts @@ -5,10 +5,11 @@ import type { BillingPaymentMethodResource, BillingPaymentResource, BillingPaymentStatus, + BillingPaymentTotals, BillingSubscriptionItemResource, } from '@clerk/shared/types'; -import { billingMoneyAmountFromJSON } from '../../utils'; +import { billingMoneyAmountFromJSON, billingPaymentTotalsFromJSON } from '../../utils'; import { unixEpochToDate } from '../../utils/date'; import { BaseResource, BillingPaymentMethod, BillingSubscriptionItem } from './internal'; @@ -22,6 +23,7 @@ export class BillingPayment extends BaseResource implements BillingPaymentResour subscriptionItem!: BillingSubscriptionItemResource; chargeType!: BillingPaymentChargeType; status!: BillingPaymentStatus; + totals: BillingPaymentTotals | null = null; constructor(data: BillingPaymentJSON) { super(); @@ -42,6 +44,7 @@ export class BillingPayment extends BaseResource implements BillingPaymentResour this.subscriptionItem = new BillingSubscriptionItem(data.subscription_item); this.chargeType = data.charge_type; this.status = data.status; + this.totals = data.totals ? billingPaymentTotalsFromJSON(data.totals) : null; return this; } } diff --git a/packages/clerk-js/src/core/resources/BillingPlan.ts b/packages/clerk-js/src/core/resources/BillingPlan.ts index fb5ab1b0ad6..821961b916e 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: price.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/__tests__/billing.test.ts b/packages/clerk-js/src/utils/__tests__/billing.test.ts new file mode 100644 index 00000000000..5b8afccbc29 --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/billing.test.ts @@ -0,0 +1,70 @@ +import type { BillingMoneyAmountJSON, BillingPaymentTotalsJSON } from '@clerk/shared/types'; +import { describe, expect, it } from 'vitest'; + +import { billingPaymentTotalsFromJSON } from '../billing'; + +const moneyJSON = (amount: number): BillingMoneyAmountJSON => ({ + amount, + amount_formatted: (amount / 100).toFixed(2), + currency: 'USD', + currency_symbol: '$', +}); + +describe('billingPaymentTotalsFromJSON', () => { + it('maps subtotal, grand_total, and tax_total', () => { + const data: BillingPaymentTotalsJSON = { + subtotal: moneyJSON(4500), + grand_total: moneyJSON(5000), + tax_total: moneyJSON(500), + }; + + const totals = billingPaymentTotalsFromJSON(data); + + expect(totals.subtotal.amount).toBe(4500); + expect(totals.grandTotal.amount).toBe(5000); + expect(totals.taxTotal.amount).toBe(500); + expect(totals.baseFee).toBeNull(); + expect(totals.perUnitTotals).toBeUndefined(); + }); + + it('maps base_fee when present', () => { + const data: BillingPaymentTotalsJSON = { + subtotal: moneyJSON(5000), + grand_total: moneyJSON(5000), + tax_total: moneyJSON(0), + base_fee: moneyJSON(1000), + }; + + expect(billingPaymentTotalsFromJSON(data).baseFee?.amount).toBe(1000); + }); + + it('maps per_unit_totals tiers with snake_case → camelCase conversion', () => { + const data: BillingPaymentTotalsJSON = { + subtotal: moneyJSON(5000), + grand_total: moneyJSON(5000), + tax_total: moneyJSON(0), + per_unit_totals: [ + { + name: 'seats', + block_size: 1, + tiers: [ + { quantity: 5, fee_per_block: moneyJSON(1000), total: moneyJSON(5000) }, + { quantity: null, fee_per_block: moneyJSON(0), total: moneyJSON(0) }, + ], + }, + ], + }; + + const totals = billingPaymentTotalsFromJSON(data); + + expect(totals.perUnitTotals).toHaveLength(1); + expect(totals.perUnitTotals?.[0].name).toBe('seats'); + expect(totals.perUnitTotals?.[0].blockSize).toBe(1); + expect(totals.perUnitTotals?.[0].tiers[0]).toMatchObject({ + quantity: 5, + feePerBlock: { amount: 1000 }, + total: { amount: 5000 }, + }); + expect(totals.perUnitTotals?.[0].tiers[1].quantity).toBeNull(); + }); +}); diff --git a/packages/clerk-js/src/utils/billing.ts b/packages/clerk-js/src/utils/billing.ts index 77b28782197..4e0fd1528c1 100644 --- a/packages/clerk-js/src/utils/billing.ts +++ b/packages/clerk-js/src/utils/billing.ts @@ -5,8 +5,11 @@ import type { BillingCreditsJSON, BillingMoneyAmount, BillingMoneyAmountJSON, + BillingPaymentTotals, + BillingPaymentTotalsJSON, BillingPerUnitTotal, BillingPerUnitTotalJSON, + BillingPlanUnitPriceJSON, BillingStatementTotals, BillingStatementTotalsJSON, } from '@clerk/shared/types'; @@ -32,6 +35,27 @@ 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 billingPaymentTotalsFromJSON = (data: BillingPaymentTotalsJSON): BillingPaymentTotals => { + return { + subtotal: billingMoneyAmountFromJSON(data.subtotal), + grandTotal: billingMoneyAmountFromJSON(data.grand_total), + taxTotal: billingMoneyAmountFromJSON(data.tax_total), + baseFee: data.base_fee ? billingMoneyAmountFromJSON(data.base_fee) : null, + perUnitTotals: data.per_unit_totals ? billingPerUnitTotalsFromJSON(data.per_unit_totals) : undefined, + }; +}; + export const billingCreditsFromJSON = (data: BillingCreditsJSON): BillingCredits => { return { proration: data.proration @@ -77,6 +101,9 @@ export const billingTotalsFromJSON = { 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 786887fd2b6..de47c269532 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. * @@ -505,6 +516,35 @@ export type BillingPaymentChargeType = 'checkout' | 'recurring'; */ export type BillingPaymentStatus = 'pending' | 'paid' | 'failed'; +/** + * The `BillingPaymentTotals` type represents the per-payment cost breakdown, including any base fee + * and per-unit (for example, seats) subtotals. + * + * @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. + */ +export interface BillingPaymentTotals { + /** + * The price of the items before taxes, credits, or discounts are applied. + */ + subtotal: BillingMoneyAmount; + /** + * The total amount for the payment, including taxes and after credits/discounts are applied. + */ + grandTotal: BillingMoneyAmount; + /** + * The amount of tax included in the payment. + */ + taxTotal: BillingMoneyAmount; + /** + * The flat base fee charged on top of any per-unit fees. + */ + baseFee?: BillingMoneyAmount | null; + /** + * Per-unit cost breakdown for this payment (for example, seats). + */ + perUnitTotals?: BillingPerUnitTotal[]; +} + /** * The `BillingPaymentResource` type represents a payment attempt for a user or Organization. * @@ -547,6 +587,11 @@ export interface BillingPaymentResource extends ClerkResource { * The current status of the payment. */ status: BillingPaymentStatus; + /** + * Per-payment breakdown with optional base fee and per-unit (for example, seats) subtotals. + * Absent on older responses. + */ + totals?: BillingPaymentTotals | null; } /** @@ -656,6 +701,7 @@ export interface BillingSubscriptionItemResource extends ClerkResource { * The billing period for the subscription item. */ planPeriod: BillingSubscriptionPlanPeriod; + priceId: string; /** * The status of the subscription item. */ @@ -840,6 +886,10 @@ export interface BillingCheckoutTotals { * The amount that needs to be immediately paid to complete the checkout. */ totalDueNow: BillingMoneyAmount; + /** + * The amount that will be charged per period for this subscription. + */ + totalDuePerPeriod: BillingMoneyAmount; /** * Any credits (like account balance or promo credits) that are being applied to the checkout. */ @@ -889,6 +939,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 7c91ed39498..1c2afdf21b4 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[]; } /** @@ -740,6 +749,19 @@ export interface BillingStatementGroupJSON extends ClerkResourceJSON { items: BillingPaymentJSON[]; } +/** + * @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. + * + * Per-payment cost breakdown including optional base fee and per-unit (for example, seats) subtotals. + */ +export interface BillingPaymentTotalsJSON { + subtotal: BillingMoneyAmountJSON; + grand_total: BillingMoneyAmountJSON; + tax_total: BillingMoneyAmountJSON; + base_fee?: BillingMoneyAmountJSON | null; + per_unit_totals?: BillingPerUnitTotalJSON[]; +} + /** * @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. */ @@ -754,6 +776,11 @@ export interface BillingPaymentJSON extends ClerkResourceJSON { subscription_item: BillingSubscriptionItemJSON; charge_type: BillingPaymentChargeType; status: BillingPaymentStatus; + /** + * Per-payment breakdown with optional base fee and per-unit (for example, seats) + * subtotals. Absent on older responses. + */ + totals?: BillingPaymentTotalsJSON | null; } /** @@ -774,6 +801,7 @@ export interface BillingSubscriptionItemJSON extends ClerkResourceJSON { credits?: BillingCreditsJSON; plan: BillingPlanJSON; plan_period: BillingSubscriptionPlanPeriod; + price_id: string; status: BillingSubscriptionStatus; created_at: number; period_start: number; @@ -860,6 +888,7 @@ export interface BillingCheckoutTotalsJSON { */ per_unit_totals?: BillingPerUnitTotalJSON[]; total_due_now: BillingMoneyAmountJSON; + total_due_per_period: BillingMoneyAmountJSON; credit: BillingMoneyAmountJSON | null; credits: BillingCreditsJSON | null; account_credit: BillingMoneyAmountJSON | null; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 3ce3be259e3..aa8c45d5d80 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -191,6 +191,11 @@ export type __internal_LocalizationResource = { keepSubscription: LocalizationValue; reSubscribe: LocalizationValue; seats: LocalizationValue; + seatsWithLimit: LocalizationValue<'limit'>; + seatBreakdownSingular: LocalizationValue<'rate'>; + seatBreakdownPlural: LocalizationValue<'chargeable' | 'rate'>; + seatBreakdownIncludedSingular: LocalizationValue<'totalSeats' | 'included' | 'rate'>; + seatBreakdownIncludedPlural: LocalizationValue<'totalSeats' | 'included' | 'chargeable' | 'rate'>; subscribe: LocalizationValue; startFreeTrial: LocalizationValue; startFreeTrial__days: LocalizationValue<'days'>; @@ -301,6 +306,7 @@ export type __internal_LocalizationResource = { downgradeNotice: LocalizationValue; pastDueNotice: LocalizationValue; totalDueAfterTrial: LocalizationValue<'days'>; + totalDuePerPeriod: LocalizationValue; perMonth: LocalizationValue; }; }; @@ -1109,6 +1115,7 @@ export type __internal_LocalizationResource = { successMessage: LocalizationValue; detailsTitle__inviteFailed: LocalizationValue<'email_addresses'>; formButtonPrimary__continue: LocalizationValue; + formButtonPrimary__purchaseSeats: LocalizationValue; selectDropdown__role: LocalizationValue; }; removeDomainPage: { @@ -1694,4 +1701,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/CheckoutForm.tsx b/packages/ui/src/components/Checkout/CheckoutForm.tsx index 2a6dc9c4122..2adca757fe4 100644 --- a/packages/ui/src/components/Checkout/CheckoutForm.tsx +++ b/packages/ui/src/components/Checkout/CheckoutForm.tsx @@ -9,7 +9,12 @@ import { LineItems } from '@/ui/elements/LineItems'; import { SegmentedControl } from '@/ui/elements/SegmentedControl'; import { Select, SelectButton, SelectOptionList } from '@/ui/elements/Select'; import { Tooltip } from '@/ui/elements/Tooltip'; -import { getSeatUnitPrice } from '@/ui/utils/billingPlanSeats'; +import { + getCheckoutSeatUnitTotal, + getIncludedSeatsUnitTotalTier, + getPaidSeatsUnitTotalTier, + getSeatUnitPrice, +} from '@/ui/utils/billingPlanSeats'; import { handleError } from '@/ui/utils/errorHandler'; import { DevOnly } from '../../common/DevOnly'; @@ -48,10 +53,21 @@ export const CheckoutForm = withCardStateProvider(() => { : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion plan.annualMonthlyFee!; - const descriptionElements = []; + const seatPerUnitTotal = getCheckoutSeatUnitTotal(totals); + const includedSeatsTier = getIncludedSeatsUnitTotalTier(seatPerUnitTotal); + const paidSeatsTier = getPaidSeatsUnitTotalTier(seatPerUnitTotal); + + const descriptionElements: Array> = []; if (planPeriod === 'annual') { descriptionElements.push(localizationKeys('billing.billedAnnually')); } + if (includedSeatsTier && includedSeatsTier.quantity !== null) { + descriptionElements.push( + localizationKeys('billing.pricingTable.seatCost.includedSeats', { + includedSeats: includedSeatsTier.quantity, + }), + ); + } const seatUnitPrice = getSeatUnitPrice(plan); if (seatUnitPrice && seatUnitPrice.tiers.length === 1 && seatUnitPrice.tiers[0].feePerBlock.amount === 0) { descriptionElements.push( @@ -91,6 +107,16 @@ export const CheckoutForm = withCardStateProvider(() => { suffix={localizationKeys('billing.checkout.perMonth')} /> + {paidSeatsTier && paidSeatsTier.quantity !== null && ( + + + + + )} { )} - {!!freeTrialEndsAt && !!plan.freeTrialDays && totals.totalDueAfterFreeTrial && ( + {!!freeTrialEndsAt && !!plan.freeTrialDays && totals.totalDueAfterFreeTrial ? ( { text={`${totals.totalDueAfterFreeTrial.currencySymbol}${totals.totalDueAfterFreeTrial.amountFormatted}`} /> + ) : ( + + + + )} 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..0a8dcc9682d 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'; @@ -8,11 +8,17 @@ 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'; 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 +37,16 @@ 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 { data: subscription, subscriptionItems } = useSubscription(); + const activeSubscriptionItem = subscription?.subscriptionItems.find(si => si.status === 'active'); + const { handleSelectPlan } = usePlansContext(); const card = useCardState(); const { t, locale } = useLocalizations(); const [isValidUnsubmittedEmail, setIsValidUnsubmittedEmail] = useState(false); @@ -74,73 +84,133 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => { } = emailAddressField; const canSubmit = (!!emailAddressField.value.length || isValidUnsubmittedEmail) && !!roleField.value; + const emailAddresses = emailAddressField.value.split(','); - const onSubmit = (e: FormEvent) => { + 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(); 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': { + try { + 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; + } + + 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; - } - 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), - ); - + } catch (err: unknown) { + if (err instanceof Error) { + handleError(err, [], () => + card.setError(t(localizationKeys('unstable__errors.insufficient_seats_contact_support'))), + ); + } break; } - default: { - handleError(err, [], card.setError); - } } - }); + default: { + handleError(err, [], card.setError); + } + } + } }; const removeInvalidEmails = (err: ClerkAPIError) => { @@ -176,7 +246,11 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => { { 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 = (