Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/billing-seat-tier-rows-payment-attempt.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/good-ads-greet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': minor
---

Add support for rendering per-seat costs in checkout
8 changes: 8 additions & 0 deletions .changeset/khaki-hairs-punch.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/modules/billing/namespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ export class Billing implements BillingNamespace {
}

getPlans = async (params?: GetPlansParams): Promise<ClerkPaginatedResponse<BillingPlanResource>> => {
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',
Expand Down
19 changes: 15 additions & 4 deletions packages/clerk-js/src/core/modules/checkout/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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');
Expand All @@ -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);
Expand All @@ -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 });
Expand Down
5 changes: 4 additions & 1 deletion packages/clerk-js/src/core/resources/BillingPayment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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();
Expand All @@ -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;
}
}
20 changes: 10 additions & 10 deletions packages/clerk-js/src/core/resources/BillingPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/core/resources/BillingSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
70 changes: 70 additions & 0 deletions packages/clerk-js/src/utils/__tests__/billing.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
27 changes: 27 additions & 0 deletions packages/clerk-js/src/utils/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import type {
BillingCreditsJSON,
BillingMoneyAmount,
BillingMoneyAmountJSON,
BillingPaymentTotals,
BillingPaymentTotalsJSON,
BillingPerUnitTotal,
BillingPerUnitTotalJSON,
BillingPlanUnitPriceJSON,
BillingStatementTotals,
BillingStatementTotalsJSON,
} from '@clerk/shared/types';
Expand All @@ -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
Expand Down Expand Up @@ -77,6 +101,9 @@ export const billingTotalsFromJSON = <T extends BillingStatementTotalsJSON | Bil
if ('total_due_now' in data) {
totals.totalDueNow = billingMoneyAmountFromJSON(data.total_due_now);
}
if ('total_due_per_period' in data) {
totals.totalDuePerPeriod = billingMoneyAmountFromJSON(data.total_due_per_period);
}

if ('total_due_after_free_trial' in data) {
totals.totalDueAfterFreeTrial = data.total_due_after_free_trial
Expand Down
10 changes: 10 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export const enUS: LocalizationResource = {
title__subscriptionSuccessful: 'Success!',
title__trialSuccess: 'Trial successfully started!',
totalDueAfterTrial: 'Total Due after trial ends in {{days}} days',
totalDuePerPeriod: 'Total Due per period',
},
credit: 'Credit',
prorationCredit: 'Prorated credit',
Expand Down Expand Up @@ -167,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',
Expand Down Expand Up @@ -1203,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,
Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/components/CheckoutButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export const CheckoutButton = withClerk(
const {
planId,
planPeriod,
seatsQuantity,
priceId,
for: _for,
onSubscriptionComplete,
newSubscriptionRedirectUrl,
Expand Down Expand Up @@ -84,6 +86,8 @@ export const CheckoutButton = withClerk(
return clerk.__internal_openCheckout({
planId,
planPeriod,
seatsQuantity,
priceId,
for: _for,
onSubscriptionComplete,
newSubscriptionRedirectUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ describe('CheckoutButton', () => {
const props = {
planId: 'test_plan',
planPeriod: 'month' as const,
seatsQuantity: 7,
onSubscriptionComplete: vi.fn(),
newSubscriptionRedirectUrl: '/success',
checkoutProps: {
Expand All @@ -121,6 +122,7 @@ describe('CheckoutButton', () => {
onSubscriptionComplete: props.onSubscriptionComplete,
newSubscriptionRedirectUrl: props.newSubscriptionRedirectUrl,
planPeriod: props.planPeriod,
seatsQuantity: props.seatsQuantity,
}),
);
});
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/errors/clerkApiError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export class ClerkAPIError<Meta extends ClerkAPIErrorMeta = any> 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;
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/errors/parseError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/react/contexts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<UseCheckoutOptions>('CheckoutContext');
Expand Down
Loading