Skip to content

experiment: e2e seat-based billing demo#8009

Open
dstaley wants to merge 19 commits intomainfrom
exp/sbb
Open

experiment: e2e seat-based billing demo#8009
dstaley wants to merge 19 commits intomainfrom
exp/sbb

Conversation

@dstaley
Copy link
Member

@dstaley dstaley commented Mar 6, 2026

This PR offers an end-to-end sandbox for demoing the upcoming phase one implementation of seat-based billing.

Instructions

  1. Go to preview
  2. Sign in, and create an organization.
  3. Go to the Organization Profile page, note that your organization member limit is 5 members.
  4. Click the Billing tab.
  5. Click the Switch Plans button, then select Plan A. Use the test card credentials to subscribe.
  6. Go to the Organization Profile page, note that your organization member limit has increased to 15 members.

Summary by CodeRabbit

  • New Features

    • Added seat-based billing support with per-unit pricing tiers for flexible plan configurations.
    • Introduced seat usage tracking in organization member management, displaying current seat count against plan limits.
    • Enhanced pricing table to display seat-cost breakdowns, including free tier thresholds and per-seat pricing details.
  • Localization

    • Added localization keys for per-unit billing labels and seat usage information across multiple languages.

@changeset-bot
Copy link

changeset-bot bot commented Mar 6, 2026

🦋 Changeset detected

Latest commit: 69e1f77

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 21 packages
Name Type
@clerk/clerk-js Minor
@clerk/shared Minor
@clerk/chrome-extension Patch
@clerk/expo Patch
@clerk/agent-toolkit Patch
@clerk/astro Patch
@clerk/backend Patch
@clerk/expo-passkeys Patch
@clerk/express Patch
@clerk/fastify Patch
@clerk/hono Patch
@clerk/localizations Patch
@clerk/msw Patch
@clerk/nextjs Patch
@clerk/nuxt Patch
@clerk/react-router Patch
@clerk/react Patch
@clerk/tanstack-react-start Patch
@clerk/testing Patch
@clerk/ui Patch
@clerk/vue Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link

vercel bot commented Mar 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Mar 6, 2026 7:02pm

Request Review

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 6, 2026

Open in StackBlitz

@clerk/agent-toolkit

npm i https://pkg.pr.new/@clerk/agent-toolkit@8009

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@8009

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@8009

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@8009

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@8009

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@8009

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@8009

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@8009

@clerk/express

npm i https://pkg.pr.new/@clerk/express@8009

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@8009

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@8009

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@8009

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@8009

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@8009

@clerk/react

npm i https://pkg.pr.new/@clerk/react@8009

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@8009

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@8009

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@8009

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@8009

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@8009

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@8009

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@8009

commit: 69e1f77

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 6, 2026

📝 Walkthrough

Walkthrough

This pull request adds seat-based billing (SBB) support across the billing system. It introduces new type definitions for unit-price tiers, seat entitlements, and per-unit totals in both TypeScript and JSON formats. The BillingPlan resource now parses unit pricing information, and BillingSubscriptionItem tracks seat quantities. The PricingTable and OrganizationMembers UI components are updated to display seat-based pricing tiers and membership seat usage indicators. New mock scenarios for testing SBB configurations are added to the sandbox. Localization keys for seat cost displays, abbreviations, and membership usage labels are introduced.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'experiment: e2e seat-based billing demo' directly and clearly describes the main change—adding end-to-end seat-based billing demo functionality—which aligns with the changeset's focus on parsing SBB fields, adding UI components, and introducing billing-related types.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx (1)

37-47: ⚠️ Potential issue | 🟠 Major

Fix seat-usage gating and remove dead expression (currently hides valid usage and risks lint failure).

Line 47 is a no-op expression, and Lines 180-214 gate visibility on memberships?.count only. This hides the bar when invitations exist but memberships are zero, even though invitation counts are included in displayed usage.

Suggested fix
   const { membershipRequests, memberships, invitations, organization } = useOrganization({
@@
   });
-  organization?.maxAllowedMemberships;
+  const seatUsageCount = (memberships?.count ?? 0) + (invitations?.count ?? 0);
@@
-      {canReadMemberships && !!memberships?.count && organization?.maxAllowedMemberships && (
+      {canReadMemberships && seatUsageCount > 0 && organization?.maxAllowedMemberships != null && (
@@
             <Text
               as='span'
               colorScheme='inherit'
               localizationKey={localizationKeys('organizationProfile.start.membershipSeatUsageLabel', {
-                count: memberships.count + (invitations?.count ?? 0),
+                count: seatUsageCount,
                 limit: organization.maxAllowedMemberships,
               })}
             />
As per coding guidelines "`**/*.{js,jsx,ts,tsx}`: All code must pass ESLint checks with the project's configuration".

Also applies to: 180-214

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx`
around lines 37 - 47, Remove the dead no-op expression
organization?.maxAllowedMemberships and update the seat-usage visibility/gating
logic in OrganizationMembers so it considers invitations as well as memberships:
replace any checks that only use memberships?.count with a computed total (e.g.,
totalUsage = (memberships?.count ?? 0) + (invitations?.count ?? 0)) and use
totalUsage for showing the usage bar and related gating; keep useOrganization
call and preserve membershipRequests/invitations/memberships usage but ensure
invitations are not ignored when deciding visibility.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/msw/request-handlers.ts`:
- Around line 1117-1120: The memberships endpoint returns inconsistent shapes:
one branch returns an object with response.data (as shown by response: { data:
[SessionService.serialize(membership)], total_count: 1 }) while fallback
branches return top-level data; fix by making all branches return the same shape
— wrap fallback payloads in the same response object (response: { data: [...],
total_count: N }) so every code path in the memberships handler in
packages/msw/request-handlers.ts uses response.data and total_count
consistently.

In `@packages/ui/src/components/PricingTable/PricingTable.tsx`:
- Line 27: Remove the debug console.log in the PricingTable component that
prints plansToRender/plans/subscription; delete the line containing
console.log('plansToRender', { plansToRender, plans, subscription }) in
PricingTable.tsx and either remove the logging entirely or replace it with the
app's centralized logger at an appropriate level (e.g., logger.debug) ensuring
any logged data is sanitized or gated behind a debug/ENV flag so sensitive
billing/subscription payloads are never emitted to the browser console in
production.

In `@packages/ui/src/components/PricingTable/PricingTableDefault.tsx`:
- Around line 325-327: The rendering crashes because code in
PricingTableDefault.tsx accesses plan.unitPrices[0] without ensuring the array
has elements (see the conditional using plan.hasBaseFee and plan.unitPrices and
the similar use at line ~486); update both places to check that
plan.unitPrices.length > 0 (or use a safe optional/default) before reading
.[0].name and fall back to a sensible default/localization when the array is
empty so localizationKeys('billing.monthPerUnit', { unitName: ... }) is never
passed undefined.

---

Outside diff comments:
In `@packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx`:
- Around line 37-47: Remove the dead no-op expression
organization?.maxAllowedMemberships and update the seat-usage visibility/gating
logic in OrganizationMembers so it considers invitations as well as memberships:
replace any checks that only use memberships?.count with a computed total (e.g.,
totalUsage = (memberships?.count ?? 0) + (invitations?.count ?? 0)) and use
totalUsage for showing the usage bar and related gating; keep useOrganization
call and preserve membershipRequests/invitations/memberships usage but ensure
invitations are not ignored when deciding visibility.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: d68fb351-5af1-41ed-94dd-0146e5515526

📥 Commits

Reviewing files that changed from the base of the PR and between 79d0ecf and 69e1f77.

📒 Files selected for processing (17)
  • .changeset/cute-ideas-appear.md
  • packages/clerk-js/sandbox/scenarios/index.ts
  • packages/clerk-js/sandbox/scenarios/org-profile-seat-limit.ts
  • packages/clerk-js/sandbox/scenarios/pricing-table-sbb.ts
  • packages/clerk-js/sandbox/template.html
  • packages/clerk-js/src/core/resources/BillingPlan.ts
  • packages/clerk-js/src/core/resources/BillingSubscription.ts
  • packages/clerk-js/src/utils/billing.ts
  • packages/localizations/src/en-US.ts
  • packages/msw/request-handlers.ts
  • packages/shared/src/types/billing.ts
  • packages/shared/src/types/json.ts
  • packages/shared/src/types/localization.ts
  • packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx
  • packages/ui/src/components/PricingTable/PricingTable.tsx
  • packages/ui/src/components/PricingTable/PricingTableDefault.tsx
  • packages/ui/src/components/PricingTable/__tests__/PricingTable.test.tsx

Comment on lines +1117 to +1120
response: {
data: [SessionService.serialize(membership)],
total_count: 1,
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Inconsistent response schema in the same memberships endpoint

After Line 1117, one code path returns response.data, but fallback paths (Line 1128 and Line 1134) still return top-level data. This makes the endpoint shape nondeterministic and can break consumers at runtime.

Suggested fix
   // Fall back to current membership if it matches
   if (currentMembership && currentOrganization?.id === orgId) {
     return createNoStoreResponse({
-      data: [SessionService.serialize(currentMembership)],
-      total_count: 1,
+      response: {
+        data: [SessionService.serialize(currentMembership)],
+        total_count: 1,
+      },
     });
   }

   return createNoStoreResponse({
-    data: [],
-    total_count: 0,
+    response: {
+      data: [],
+      total_count: 0,
+    },
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
response: {
data: [SessionService.serialize(membership)],
total_count: 1,
},
// Fall back to current membership if it matches
if (currentMembership && currentOrganization?.id === orgId) {
return createNoStoreResponse({
response: {
data: [SessionService.serialize(currentMembership)],
total_count: 1,
},
});
}
return createNoStoreResponse({
response: {
data: [],
total_count: 0,
},
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/msw/request-handlers.ts` around lines 1117 - 1120, The memberships
endpoint returns inconsistent shapes: one branch returns an object with
response.data (as shown by response: { data:
[SessionService.serialize(membership)], total_count: 1 }) while fallback
branches return top-level data; fix by making all branches return the same shape
— wrap fallback payloads in the same response object (response: { data: [...],
total_count: N }) so every code path in the memberships handler in
packages/msw/request-handlers.ts uses response.data and total_count
consistently.

: []
: plans;
}, [clerk.isSignedIn, plans, subscription]);
console.log('plansToRender', { plansToRender, plans, subscription });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove debug logging of subscription/plan payloads

Line 27 logs billing/subscription state to the browser console. This risks leaking sensitive account/billing data and should not ship.

Suggested fix
-  console.log('plansToRender', { plansToRender, plans, subscription });

As per coding guidelines: "Implement proper logging with different levels".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log('plansToRender', { plansToRender, plans, subscription });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ui/src/components/PricingTable/PricingTable.tsx` at line 27, Remove
the debug console.log in the PricingTable component that prints
plansToRender/plans/subscription; delete the line containing
console.log('plansToRender', { plansToRender, plans, subscription }) in
PricingTable.tsx and either remove the logging entirely or replace it with the
app's centralized logger at an appropriate level (e.g., logger.debug) ensuring
any logged data is sanitized or gated behind a debug/ENV flag so sensitive
billing/subscription payloads are never emitted to the browser console in
production.

Comment on lines +325 to +327
if (!plan.hasBaseFee && plan.unitPrices) {
return localizationKeys('billing.monthPerUnit', { unitName: plan.unitPrices[0].name });
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Guard unitPrices[0] access — empty arrays currently crash rendering.

Line 326 and Line 486 assume plan.unitPrices[0] exists when unitPrices is truthy. An empty array will throw at runtime.

Suggested fix
   const feePeriodText = React.useMemo(() => {
-    if (!plan.hasBaseFee && plan.unitPrices) {
-      return localizationKeys('billing.monthPerUnit', { unitName: plan.unitPrices[0].name });
+    const firstUnitPrice = plan.unitPrices?.[0];
+    if (!plan.hasBaseFee && firstUnitPrice) {
+      return localizationKeys('billing.monthPerUnit', { unitName: firstUnitPrice.name });
     }

     return localizationKeys('billing.month');
-  }, [plan.unitPrices]);
+  }, [plan.hasBaseFee, plan.unitPrices]);
@@
-        {plan.unitPrices && (plan.hasBaseFee || plan.unitPrices[0].tiers.length > 0) ? (
+        {plan.unitPrices && (plan.hasBaseFee || (plan.unitPrices[0]?.tiers.length ?? 0) > 0) ? (
           <CardFeaturesListSeatCost plan={plan} />
         ) : null}

Also applies to: 486-486

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ui/src/components/PricingTable/PricingTableDefault.tsx` around lines
325 - 327, The rendering crashes because code in PricingTableDefault.tsx
accesses plan.unitPrices[0] without ensuring the array has elements (see the
conditional using plan.hasBaseFee and plan.unitPrices and the similar use at
line ~486); update both places to check that plan.unitPrices.length > 0 (or use
a safe optional/default) before reading .[0].name and fall back to a sensible
default/localization when the array is empty so
localizationKeys('billing.monthPerUnit', { unitName: ... }) is never passed
undefined.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant