From e6c035973d228546352f8875f136db04b601e4ef Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Fri, 8 May 2026 10:48:16 -0500 Subject: [PATCH 1/4] feat(ui): Add per-seat costs to checkout totals (#8464) --- .changeset/good-ads-greet.md | 5 ++ .changeset/khaki-hairs-punch.md | 8 +++ packages/clerk-js/src/utils/billing.ts | 3 + packages/localizations/src/en-US.ts | 1 + packages/shared/src/types/billing.ts | 4 ++ packages/shared/src/types/json.ts | 1 + packages/shared/src/types/localization.ts | 1 + .../src/components/Checkout/CheckoutForm.tsx | 39 +++++++++- packages/ui/src/elements/LineItems.tsx | 1 - packages/ui/src/utils/billingPlanSeats.ts | 71 ++++++++++++++++++- 10 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 .changeset/good-ads-greet.md create mode 100644 .changeset/khaki-hairs-punch.md 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/utils/billing.ts b/packages/clerk-js/src/utils/billing.ts index 77b28782197..e994843361f 100644 --- a/packages/clerk-js/src/utils/billing.ts +++ b/packages/clerk-js/src/utils/billing.ts @@ -77,6 +77,9 @@ export const billingTotalsFromJSON = ; + totalDuePerPeriod: LocalizationValue; perMonth: 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/elements/LineItems.tsx b/packages/ui/src/elements/LineItems.tsx index ab5173392ba..c6e35f6938f 100644 --- a/packages/ui/src/elements/LineItems.tsx +++ b/packages/ui/src/elements/LineItems.tsx @@ -112,7 +112,6 @@ const Title = React.forwardRef(({ title, descr ({ display: 'inline-flex', - alignItems: 'center', gap: t.space.$1, })} > diff --git a/packages/ui/src/utils/billingPlanSeats.ts b/packages/ui/src/utils/billingPlanSeats.ts index e5732c2d530..c7805ee8f2b 100644 --- a/packages/ui/src/utils/billingPlanSeats.ts +++ b/packages/ui/src/utils/billingPlanSeats.ts @@ -1,4 +1,10 @@ -import type { BillingPlanResource, BillingPlanUnitPrice, OrganizationResource } from '@clerk/shared/types'; +import type { + BillingPerUnitTotal, + BillingPerUnitTotalTier, + BillingPlanResource, + BillingPlanUnitPrice, + OrganizationResource, +} from '@clerk/shared/types'; /** * Given a plan, return the unit price for seats. @@ -17,6 +23,69 @@ export const getSeatUnitPrice = (plan: { unitPrices?: BillingPlanUnitPrice[] }): return null; }; +/** + * Similar to the above, given a checkout totals, return the unit price for seats. + */ +export const getCheckoutSeatUnitTotal = (checkout: { + perUnitTotals?: BillingPerUnitTotal[]; +}): BillingPerUnitTotal | null => { + if (!checkout.perUnitTotals?.length) { + return null; + } + + const seatUnitPrice = checkout.perUnitTotals.find(unitTotal => unitTotal.name === 'seats'); + + if (seatUnitPrice) { + return seatUnitPrice; + } + + return null; +}; + +/** + * Given a checkout unit total, return the unit total tier that represents per-seat costs. If no tier is found, return null. + */ +export const getPaidSeatsUnitTotalTier = (unitTotal: BillingPerUnitTotal | null): BillingPerUnitTotalTier | null => { + if (!unitTotal) { + return null; + } + + if (unitTotal.tiers.length === 1 && unitTotal.tiers[0].feePerBlock.amount > 0) { + return unitTotal.tiers[0]; + } + + if ( + unitTotal.tiers.length === 2 && + unitTotal.tiers[0].feePerBlock.amount === 0 && + unitTotal.tiers[1].feePerBlock.amount > 0 + ) { + return unitTotal.tiers[1]; + } + + return null; +}; + +/** + * Given a checkout unit total, return the unit total tier that represents included seats. If no tier is found, return null. + */ +export const getIncludedSeatsUnitTotalTier = ( + unitTotal: BillingPerUnitTotal | null, +): BillingPerUnitTotalTier | null => { + if (!unitTotal) { + return null; + } + + if ( + unitTotal.tiers.length === 2 && + unitTotal.tiers[0].feePerBlock.amount === 0 && + unitTotal.tiers[1].feePerBlock.amount > 0 + ) { + return unitTotal.tiers[0]; + } + + return null; +}; + /** * Given a plan, return the seat limit for the plan, or undefined if the plan does not have a seat limit. */ From c4675c25fd11de4ffba8a5f2e344a40121fe7f2b Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Tue, 19 May 2026 14:50:00 -0700 Subject: [PATCH 2/4] feat(clerk-js,shared,ui): Add seats info to payment attempts page (#8527) --- .../billing-seat-tier-rows-payment-attempt.md | 7 + .../src/core/resources/BillingPayment.ts | 5 +- .../src/utils/__tests__/billing.test.ts | 70 ++++++++++ packages/clerk-js/src/utils/billing.ts | 12 ++ packages/localizations/src/en-US.ts | 5 + packages/shared/src/types/billing.ts | 34 +++++ packages/shared/src/types/json.ts | 18 +++ packages/shared/src/types/localization.ts | 5 + .../PaymentAttempts/PaymentAttemptPage.tsx | 54 +++++++- .../utils/__tests__/billingPlanSeats.test.ts | 123 ++++++++++++++++++ packages/ui/src/utils/billingPlanSeats.ts | 55 +++++++- 11 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 .changeset/billing-seat-tier-rows-payment-attempt.md create mode 100644 packages/clerk-js/src/utils/__tests__/billing.test.ts create mode 100644 packages/ui/src/utils/__tests__/billingPlanSeats.test.ts 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/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/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 e994843361f..f53c87eaaff 100644 --- a/packages/clerk-js/src/utils/billing.ts +++ b/packages/clerk-js/src/utils/billing.ts @@ -5,6 +5,8 @@ import type { BillingCreditsJSON, BillingMoneyAmount, BillingMoneyAmountJSON, + BillingPaymentTotals, + BillingPaymentTotalsJSON, BillingPerUnitTotal, BillingPerUnitTotalJSON, BillingStatementTotals, @@ -32,6 +34,16 @@ const billingPerUnitTotalsFromJSON = (data: BillingPerUnitTotalJSON[]): BillingP })); }; +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 diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index be7dcff8482..7921820d856 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -168,6 +168,11 @@ export const enUS: LocalizationResource = { }, reSubscribe: 'Resubscribe', seats: 'Seats', + seatsWithLimit: 'Seats (up to {{limit}})', + seatBreakdownSingular: '1 seat at {{rate}}/mo', + seatBreakdownPlural: '{{chargeable}} seats at {{rate}}/mo', + seatBreakdownIncludedSingular: '1 seat at {{rate}}/mo ({{totalSeats}} total - {{included}} included)', + seatBreakdownIncludedPlural: '{{chargeable}} seats at {{rate}}/mo ({{totalSeats}} total - {{included}} included)', seeAllFeatures: 'See all features', startFreeTrial: 'Start free trial', startFreeTrial__days: 'Start {{days}}-day free trial', diff --git a/packages/shared/src/types/billing.ts b/packages/shared/src/types/billing.ts index 151f72db0d5..88803d218fe 100644 --- a/packages/shared/src/types/billing.ts +++ b/packages/shared/src/types/billing.ts @@ -505,6 +505,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 +576,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; } /** diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 4f26f9b7c8c..d74f4e4bfe5 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -740,6 +740,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 +767,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; } /** diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 3bb00ce6831..39d5420ff12 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'>; diff --git a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx index 2ebd2973aa2..3b796b5feea 100644 --- a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -1,9 +1,10 @@ import { __internal_usePaymentAttemptQuery } from '@clerk/shared/react/index'; -import type { BillingSubscriptionItemResource } from '@clerk/shared/types'; +import type { BillingPaymentResource } from '@clerk/shared/types'; import { Alert } from '@/ui/elements/Alert'; import { Header } from '@/ui/elements/Header'; import { LineItems } from '@/ui/elements/LineItems'; +import { getPlanSeatLimit, getSeatsPerUnitTotal, summarizeSeatCharges } from '@/ui/utils/billingPlanSeats'; import { formatDate } from '@/ui/utils/formatDate'; import { truncateWithEndVisible } from '@/ui/utils/truncateTextWithEndVisible'; @@ -42,8 +43,6 @@ export const PaymentAttemptPage = () => { enabled: Boolean(params.paymentAttemptId), }); - const subscriptionItem = paymentAttempt?.subscriptionItem; - if (isLoading) { return ( @@ -147,7 +146,7 @@ export const PaymentAttemptPage = () => { {paymentAttempt.status} - + { ); }; -function PaymentAttemptBody({ subscriptionItem }: { subscriptionItem: BillingSubscriptionItemResource | undefined }) { - if (!subscriptionItem) { +function PaymentAttemptBody({ paymentAttempt }: { paymentAttempt: BillingPaymentResource | undefined }) { + if (!paymentAttempt) { return null; } + const { subscriptionItem } = paymentAttempt; + const fee = subscriptionItem.planPeriod === 'month' ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -210,6 +211,11 @@ function PaymentAttemptBody({ subscriptionItem }: { subscriptionItem: BillingSub : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion subscriptionItem.plan.annualMonthlyFee!; + const seatsTotal = subscriptionItem.seats != null ? getSeatsPerUnitTotal(paymentAttempt.totals) : undefined; + const seatSummary = summarizeSeatCharges(seatsTotal); + const seatsChargeable = seatSummary ? seatSummary.totalSeats - seatSummary.included : 0; + const planSeatLimit = getPlanSeatLimit(subscriptionItem.plan); + return ( + {seatSummary && ( + + { + const rate = `${seatSummary.paidTier.feePerBlock.currencySymbol}${seatSummary.paidTier.feePerBlock.amountFormatted}`; + const isSingular = seatsChargeable === 1; + if (seatSummary.included > 0) { + return isSingular + ? localizationKeys('billing.seatBreakdownIncludedSingular', { + totalSeats: seatSummary.totalSeats, + included: seatSummary.included, + rate, + }) + : localizationKeys('billing.seatBreakdownIncludedPlural', { + totalSeats: seatSummary.totalSeats, + included: seatSummary.included, + chargeable: seatsChargeable, + rate, + }); + } + return isSingular + ? localizationKeys('billing.seatBreakdownSingular', { rate }) + : localizationKeys('billing.seatBreakdownPlural', { chargeable: seatsChargeable, rate }); + })()} + /> + + + )} ({ + amount, + amountFormatted: (amount / 100).toFixed(2), + currency: 'USD', + currencySymbol: '$', +}); + +const baseTotals = (): BillingPaymentTotals => ({ + subtotal: money(5000), + grandTotal: money(5000), + taxTotal: money(0), +}); + +describe('getSeatsPerUnitTotal', () => { + test('returns undefined when totals is null', () => { + expect(getSeatsPerUnitTotal(null)).toBeUndefined(); + }); + + test('returns undefined when totals is undefined', () => { + expect(getSeatsPerUnitTotal(undefined)).toBeUndefined(); + }); + + test('returns undefined when perUnitTotals is absent', () => { + expect(getSeatsPerUnitTotal(baseTotals())).toBeUndefined(); + }); + + test('returns undefined when no per-unit total has name "seats"', () => { + const totals: BillingPaymentTotals = { + ...baseTotals(), + perUnitTotals: [ + { + name: 'requests', + blockSize: 1, + tiers: [{ quantity: 100, feePerBlock: money(10), total: money(1000) }], + }, + ], + }; + expect(getSeatsPerUnitTotal(totals)).toBeUndefined(); + }); + + test('finds the seats per-unit total', () => { + const seats = { + name: 'seats', + blockSize: 1, + tiers: [{ quantity: 5, feePerBlock: money(1000), total: money(5000) }], + }; + const totals: BillingPaymentTotals = { ...baseTotals(), perUnitTotals: [seats] }; + expect(getSeatsPerUnitTotal(totals)).toBe(seats); + }); + + test('matches "seats" case-insensitively', () => { + const seats = { + name: 'Seats', + blockSize: 1, + tiers: [{ quantity: null, feePerBlock: money(0), total: money(0) }], + }; + const totals: BillingPaymentTotals = { ...baseTotals(), perUnitTotals: [seats] }; + expect(getSeatsPerUnitTotal(totals)).toBe(seats); + }); +}); + +describe('summarizeSeatCharges', () => { + test('returns null when seatsTotal is undefined', () => { + expect(summarizeSeatCharges(undefined)).toBeNull(); + }); + + test('returns null when no tier has a positive fee (plan with only a free tier / under-included scenario)', () => { + const seats: BillingPerUnitTotal = { + name: 'seats', + blockSize: 1, + tiers: [{ quantity: 10, feePerBlock: money(0), total: money(0) }], + }; + expect(summarizeSeatCharges(seats)).toBeNull(); + }); + + test('summarizes a paid-only plan (no included tier)', () => { + const seats: BillingPerUnitTotal = { + name: 'seats', + blockSize: 1, + tiers: [{ quantity: 5, feePerBlock: money(500), total: money(2500) }], + }; + const summary = summarizeSeatCharges(seats); + expect(summary).not.toBeNull(); + expect(summary!.totalSeats).toBe(5); + expect(summary!.included).toBe(0); + expect(summary!.paidTier.feePerBlock.amount).toBe(500); + expect(summary!.paidTier.total.amount).toBe(2500); + }); + + test('summarizes a mixed (included + paid) plan', () => { + const seats: BillingPerUnitTotal = { + name: 'seats', + blockSize: 1, + tiers: [ + { quantity: 3, feePerBlock: money(0), total: money(0) }, + { quantity: 2, feePerBlock: money(500), total: money(1000) }, + ], + }; + const summary = summarizeSeatCharges(seats); + expect(summary).not.toBeNull(); + expect(summary!.totalSeats).toBe(5); + expect(summary!.included).toBe(3); + expect(summary!.paidTier.feePerBlock.amount).toBe(500); + expect(summary!.paidTier.total.amount).toBe(1000); + }); + + test('treats null-quantity (unlimited) tiers as 0 in the count', () => { + const seats: BillingPerUnitTotal = { + name: 'seats', + blockSize: 1, + tiers: [{ quantity: null, feePerBlock: money(500), total: money(0) }], + }; + const summary = summarizeSeatCharges(seats); + expect(summary).not.toBeNull(); + expect(summary!.totalSeats).toBe(0); + expect(summary!.included).toBe(0); + }); +}); diff --git a/packages/ui/src/utils/billingPlanSeats.ts b/packages/ui/src/utils/billingPlanSeats.ts index c7805ee8f2b..054e4b4f2e1 100644 --- a/packages/ui/src/utils/billingPlanSeats.ts +++ b/packages/ui/src/utils/billingPlanSeats.ts @@ -1,4 +1,5 @@ import type { + BillingPaymentTotals, BillingPerUnitTotal, BillingPerUnitTotalTier, BillingPlanResource, @@ -23,6 +24,54 @@ export const getSeatUnitPrice = (plan: { unitPrices?: BillingPlanUnitPrice[] }): return null; }; +/** + * Given payment totals, return the per-unit total entry for seats, if present. + */ +export const getSeatsPerUnitTotal = ( + totals: BillingPaymentTotals | null | undefined, +): BillingPerUnitTotal | undefined => { + return totals?.perUnitTotals?.find(unitTotal => unitTotal.name.toLowerCase() === 'seats'); +}; + +export type SeatChargeSummary = { + /** + * Sum of `quantity` across all tiers (paid + included) — the seats accounted for in this + * breakdown. In every case where this helper returns a non-null summary, the backend guarantees + * `totalSeats` equals the org's occupied seat count (right-sizing only inflates counts when + * occupancy is entirely within a free tier, which is a case this helper short-circuits on). + */ + totalSeats: number; + /** Sum of `quantity` across $0 (included) tiers. `0` when the plan has no included seats. */ + included: number; + /** The first tier with `feePerBlock > 0`. Used for the rate and total. */ + paidTier: BillingPerUnitTotalTier; +}; + +/** + * Summarize a seats per-unit total for display in a payment breakdown. + * + * Returns `null` when there is no paid quantity to charge for — either because the plan has no + * per-seat pricing at all (only a seat limit), or because the org's occupied seats fall entirely + * within the included tier (right-sized by the backend so the only tier carries `feePerBlock = $0`). + * + * Returns `{ totalSeats, included, paidTier }` otherwise. + */ +export const summarizeSeatCharges = (seatsTotal: BillingPerUnitTotal | null | undefined): SeatChargeSummary | null => { + if (!seatsTotal) return null; + const paidTier = seatsTotal.tiers.find(tier => tier.feePerBlock.amount > 0); + if (!paidTier) return null; + let totalSeats = 0; + let included = 0; + for (const tier of seatsTotal.tiers) { + if (tier.quantity === null) continue; + totalSeats += tier.quantity; + if (tier.feePerBlock.amount === 0) { + included += tier.quantity; + } + } + return { totalSeats, included, paidTier }; +}; + /** * Similar to the above, given a checkout totals, return the unit price for seats. */ @@ -87,7 +136,8 @@ export const getIncludedSeatsUnitTotalTier = ( }; /** - * Given a plan, return the seat limit for the plan, or undefined if the plan does not have a seat limit. + * 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. */ export const getPlanSeatLimit = (plan: BillingPlanResource): number | null | undefined => { const seatUnitPrice = getSeatUnitPrice(plan); @@ -96,7 +146,8 @@ export const getPlanSeatLimit = (plan: BillingPlanResource): number | null | und return undefined; } - return seatUnitPrice.tiers[seatUnitPrice.tiers.length - 1]?.endsAfterBlock; + const lastTier = seatUnitPrice.tiers[seatUnitPrice.tiers.length - 1]; + return lastTier.endsAfterBlock != null ? lastTier.endsAfterBlock * seatUnitPrice.blockSize : null; }; /** From a37a87b990e7e316d90479673b49bb3f11f860a1 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Fri, 22 May 2026 13:32:17 -0500 Subject: [PATCH 3/4] feat(clerk-js,localizations,react,shared,ui): Add support for org invite to checkout flow (#8511) --- .../src/core/modules/billing/namespace.ts | 4 +- .../src/core/modules/checkout/instance.ts | 19 ++- .../src/core/resources/BillingPlan.ts | 20 +-- .../src/core/resources/BillingSubscription.ts | 2 + packages/clerk-js/src/utils/billing.ts | 12 ++ packages/localizations/src/en-US.ts | 4 + .../react/src/components/CheckoutButton.tsx | 4 + .../__tests__/CheckoutButton.test.tsx | 2 + packages/shared/src/errors/clerkApiError.ts | 2 + packages/shared/src/errors/parseError.ts | 2 + .../react/__tests__/payment-element.test.tsx | 1 + packages/shared/src/react/contexts.tsx | 2 + .../shared/src/react/hooks/useCheckout.ts | 6 +- packages/shared/src/types/billing.ts | 14 ++ packages/shared/src/types/clerk.ts | 6 + packages/shared/src/types/errors.ts | 4 + packages/shared/src/types/json.ts | 10 ++ packages/shared/src/types/localization.ts | 2 + .../src/components/Checkout/CheckoutPage.tsx | 6 +- .../Checkout/__tests__/Checkout.test.tsx | 45 +++++ .../OrganizationProfile/InviteMembersForm.tsx | 158 ++++++++++++------ .../OrganizationProfile/MembersActions.tsx | 9 +- packages/ui/src/contexts/components/Plans.tsx | 19 ++- .../src/lazyModules/MountedCheckoutDrawer.tsx | 2 + packages/ui/src/utils/billingPlanSeats.ts | 18 ++ .../src/utils/getClosestProfileScrollBox.ts | 22 ++- packages/ui/vitest.config.mts | 1 + 27 files changed, 314 insertions(+), 82 deletions(-) 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..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/billing.ts b/packages/clerk-js/src/utils/billing.ts index f53c87eaaff..4e0fd1528c1 100644 --- a/packages/clerk-js/src/utils/billing.ts +++ b/packages/clerk-js/src/utils/billing.ts @@ -9,6 +9,7 @@ import type { BillingPaymentTotalsJSON, BillingPerUnitTotal, BillingPerUnitTotalJSON, + BillingPlanUnitPriceJSON, BillingStatementTotals, BillingStatementTotalsJSON, } from '@clerk/shared/types'; @@ -34,6 +35,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 billingPaymentTotalsFromJSON = (data: BillingPaymentTotalsJSON): BillingPaymentTotals => { return { subtotal: billingMoneyAmountFromJSON(data.subtotal), diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 7921820d856..57803d65415 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1209,6 +1209,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 88803d218fe..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. * @@ -690,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. */ @@ -927,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 d74f4e4bfe5..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[]; } /** @@ -792,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; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 39d5420ff12..afafe6dcc5d 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1700,4 +1700,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..3ec23fc463a 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,124 @@ 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': { + 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) => { 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 = (