Skip to content

feat(msw): Billing service mocks#7781

Open
dstaley wants to merge 2 commits intomainfrom
ds.feat/msw-billing-service
Open

feat(msw): Billing service mocks#7781
dstaley wants to merge 2 commits intomainfrom
ds.feat/msw-billing-service

Conversation

@dstaley
Copy link
Member

@dstaley dstaley commented Feb 5, 2026

Description

This PR adds mocks for the billing endpoints to our msw package. It also updates the user button scenario to have an active plan.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

Summary by CodeRabbit

  • New Features

    • Sandbox signed-in scenario now includes billing plans and an active subscription for previewing billing UI.
  • Tests

    • Expanded billing mock infrastructure with dynamic plan, subscription, payment method, checkout, and statement generation.
    • Added comprehensive mock API coverage for billing workflows (plans, subscriptions, payments, checkouts).
  • Chores

    • Added changeset entry and a package-level type-check script.

@changeset-bot
Copy link

changeset-bot bot commented Feb 5, 2026

🦋 Changeset detected

Latest commit: 34eab63

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

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

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 Feb 5, 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 Feb 5, 2026 9:00pm

Request Review

Comment on lines +35 to +39
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '-'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '-'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '-'.
const checkout: BillingCheckoutJSON = {
object: 'commerce_checkout',
id: overrides.id ?? this.createId('chk'),
external_client_secret: overrides.external_client_secret ?? `mock_checkout_secret_${this.createId('secret')}`,

Check failure

Code scanning / CodeQL

Insecure randomness High

This uses a cryptographically insecure random number generated at
Math.random()
in a security context.

Copilot Autofix

AI about 9 hours ago

In general, to fix insecure randomness issues, replace uses of Math.random() (or other non-cryptographic PRNGs) when generating secrets, tokens, or identifiers used for authentication/authorization with a cryptographically secure random generator. In Node.js, this means using crypto.randomBytes (or crypto.randomUUID when suitable) and then encoding the bytes in a string form (hex/base64/url-safe) without introducing significant bias.

In this file, the core problem is BillingService.createId, which returns ${prefix}_${Math.random().toString(36).slice(2, 10)}. This function is then used to construct external_client_secret. The best targeted fix is to change createId so it uses Node’s crypto module to generate a random string of comparable length, while keeping the same external format (<prefix>_<8-char-random-string>). This will automatically make all uses of createId more secure (including the external_client_secret case) without changing call sites or the type/signature.

Concretely:

  • Add an import for Node’s crypto module at the top of packages/msw/BillingService.ts: import * as crypto from 'crypto';
  • Replace the body of BillingService.createId so that instead of using Math.random(), it uses crypto.randomBytes to generate 6–8 random bytes, then encodes them in base36 or hex and slices to 8 characters. For example:
    • const random = crypto.randomBytes(6).toString('base64url').slice(0, 8);
    • return \${prefix}_${random}`;`
  • Keep the function name and signature unchanged so no other code needs to be updated.

No other changes in this file are required to address the reported issue.


Suggested changeset 1
packages/msw/BillingService.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/msw/BillingService.ts b/packages/msw/BillingService.ts
--- a/packages/msw/BillingService.ts
+++ b/packages/msw/BillingService.ts
@@ -13,6 +13,7 @@
   BillingSubscriptionPlanPeriod,
   FeatureJSON,
 } from '@clerk/shared/types';
+import * as crypto from 'crypto';
 
 type BillingCheckoutTotalsWithOptionalAccountCredit = BillingCheckoutTotalsJSON & {
   account_credit?: BillingMoneyAmountJSON | null;
@@ -28,7 +29,8 @@
 
 export class BillingService {
   private static createId(prefix: string): string {
-    return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
+    const random = crypto.randomBytes(6).toString('base64url').slice(0, 8);
+    return `${prefix}_${random}`;
   }
 
   private static slugify(value: string): string {
EOF
@@ -13,6 +13,7 @@
BillingSubscriptionPlanPeriod,
FeatureJSON,
} from '@clerk/shared/types';
import * as crypto from 'crypto';

type BillingCheckoutTotalsWithOptionalAccountCredit = BillingCheckoutTotalsJSON & {
account_credit?: BillingMoneyAmountJSON | null;
@@ -28,7 +29,8 @@

export class BillingService {
private static createId(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
const random = crypto.randomBytes(6).toString('base64url').slice(0, 8);
return `${prefix}_${random}`;
}

private static slugify(value: string): string {
Copilot is powered by AI and may make mistakes. Always verify output.
id: overrides.id ?? this.createId('pmi'),
object: 'commerce_payment_method_initialize',
external_client_secret:
overrides.external_client_secret ?? `mock_client_secret_${Math.random().toString(36).slice(2, 15)}`,

Check failure

Code scanning / CodeQL

Insecure randomness High

This uses a cryptographically insecure random number generated at
Math.random()
in a security context.

Copilot Autofix

AI about 9 hours ago

General fix: replace the use of Math.random() with a cryptographically secure random generator when producing any value that is (or is modeled as) a secret. In Node, use crypto.randomBytes; in browsers, use crypto.getRandomValues. Convert the random bytes to a string in a way that doesn’t significantly reduce entropy.

Best concrete fix here:

  • Import Node’s built-in crypto module at the top of packages/msw/BillingService.ts.
  • Introduce a small helper method in BillingService, e.g. private static createRandomIdSegment(length: number): string, that uses crypto.randomBytes to get enough bytes and then encodes them (e.g. base64url) and truncates to the requested length.
  • Replace the Math.random().toString(36).slice(2, 15) expression with a call to this helper so the generated client secrets remain stringy and similar length, but are now backed by secure randomness.
  • Keep the mock_client_secret_ prefix and all other behavior the same.

Given only the shown snippet, the minimal, contained change is:

  1. Add import crypto from 'crypto'; at the top (no external deps).
  2. Add a helper in BillingService near the other statics.
  3. Change line 462 to:
    overrides.external_client_secret ?? \mock_client_secret_${this.createRandomIdSegment(13)}``

This preserves existing functionality (string format, length roughly similar) while eliminating the insecure RNG.

Suggested changeset 1
packages/msw/BillingService.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/msw/BillingService.ts b/packages/msw/BillingService.ts
--- a/packages/msw/BillingService.ts
+++ b/packages/msw/BillingService.ts
@@ -13,6 +13,7 @@
   BillingSubscriptionPlanPeriod,
   FeatureJSON,
 } from '@clerk/shared/types';
+import crypto from 'crypto';
 
 type BillingCheckoutTotalsWithOptionalAccountCredit = BillingCheckoutTotalsJSON & {
   account_credit?: BillingMoneyAmountJSON | null;
@@ -27,6 +28,16 @@
 const DEFAULT_CURRENCY_SYMBOL = '$';
 
 export class BillingService {
+  private static createRandomIdSegment(length: number): string {
+    // Generate a URL-safe base64 string and trim to the desired length.
+    const bytes = crypto.randomBytes(Math.ceil((length * 3) / 4));
+    return bytes
+      .toString('base64')
+      .replace(/\+/g, '-')
+      .replace(/\//g, '_')
+      .replace(/=+$/g, '')
+      .slice(0, length);
+  }
   private static createId(prefix: string): string {
     return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
   }
@@ -459,7 +470,8 @@
       id: overrides.id ?? this.createId('pmi'),
       object: 'commerce_payment_method_initialize',
       external_client_secret:
-        overrides.external_client_secret ?? `mock_client_secret_${Math.random().toString(36).slice(2, 15)}`,
+        overrides.external_client_secret ??
+        `mock_client_secret_${this.createRandomIdSegment(13)}`,
       external_gateway_id: overrides.external_gateway_id ?? 'stripe',
       payment_method_order: overrides.payment_method_order ?? ['card'],
     };
EOF
@@ -13,6 +13,7 @@
BillingSubscriptionPlanPeriod,
FeatureJSON,
} from '@clerk/shared/types';
import crypto from 'crypto';

type BillingCheckoutTotalsWithOptionalAccountCredit = BillingCheckoutTotalsJSON & {
account_credit?: BillingMoneyAmountJSON | null;
@@ -27,6 +28,16 @@
const DEFAULT_CURRENCY_SYMBOL = '$';

export class BillingService {
private static createRandomIdSegment(length: number): string {
// Generate a URL-safe base64 string and trim to the desired length.
const bytes = crypto.randomBytes(Math.ceil((length * 3) / 4));
return bytes
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '')
.slice(0, length);
}
private static createId(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
@@ -459,7 +470,8 @@
id: overrides.id ?? this.createId('pmi'),
object: 'commerce_payment_method_initialize',
external_client_secret:
overrides.external_client_secret ?? `mock_client_secret_${Math.random().toString(36).slice(2, 15)}`,
overrides.external_client_secret ??
`mock_client_secret_${this.createRandomIdSegment(13)}`,
external_gateway_id: overrides.external_gateway_id ?? 'stripe',
payment_method_order: overrides.payment_method_order ?? ['card'],
};
Copilot is powered by AI and may make mistakes. Always verify output.
payer: createBillingPayer(),
payment_method: paymentMethod,
needs_payment_method: !paymentMethod,
external_client_secret: `mock_checkout_secret_${checkoutId}`,

Check failure

Code scanning / CodeQL

Insecure randomness High

This uses a cryptographically insecure random number generated at
Math.random()
in a security context.

Copilot Autofix

AI about 9 hours ago

In general, any value that is used as a secret or token (like external_client_secret) must not be based on Math.random(). Instead, it should be generated using a cryptographically secure random source, such as Node’s crypto.randomUUID() or a CSPRNG-backed random byte generator. This prevents attackers from predicting or reproducing the secret.

For this specific code, the best minimal-impact fix is to replace the Math.random()-based checkoutId with an ID produced via a cryptographically secure API. Since this file is TypeScript running in a Node/MSW environment, we can safely import Node’s crypto module and use crypto.randomUUID() to generate a high-entropy random identifier. We then keep the rest of the logic the same, still prefixing with chk_mock_ so that existing downstream behavior is preserved. This automatically makes external_client_secret unpredictable as well, because it incorporates the secure checkoutId.

Concretely:

  • Add an import for randomUUID from Node’s crypto module at the top of packages/msw/request-handlers.ts.
  • Change the line that sets checkoutId so it no longer uses Math.random() and instead calls randomUUID(), maintaining the same string format with the chk_mock_ prefix.

No additional methods or helpers are required beyond the new import.


Suggested changeset 1
packages/msw/request-handlers.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/msw/request-handlers.ts b/packages/msw/request-handlers.ts
--- a/packages/msw/request-handlers.ts
+++ b/packages/msw/request-handlers.ts
@@ -1,5 +1,7 @@
 import { http, HttpResponse } from 'msw';
 
+import { randomUUID } from 'crypto';
+
 import type {
   BillingPaymentMethodJSON,
   BillingPayerJSON,
@@ -2079,7 +2081,7 @@
 
     const url = new URL(request.url);
     const body = await parseRequestBodyAsRecord(request);
-    const checkoutId = `chk_mock_${Math.random().toString(36).slice(2, 10)}`;
+    const checkoutId = `chk_mock_${randomUUID()}`;
     const preferredPlanId = readStringParam(body, ['plan_id', 'planId', 'plan'], url.searchParams);
     const rawPeriod = readStringParam(
       body,
EOF
@@ -1,5 +1,7 @@
import { http, HttpResponse } from 'msw';

import { randomUUID } from 'crypto';

import type {
BillingPaymentMethodJSON,
BillingPayerJSON,
@@ -2079,7 +2081,7 @@

const url = new URL(request.url);
const body = await parseRequestBodyAsRecord(request);
const checkoutId = `chk_mock_${Math.random().toString(36).slice(2, 10)}`;
const checkoutId = `chk_mock_${randomUUID()}`;
const preferredPlanId = readStringParam(body, ['plan_id', 'planId', 'plan'], url.searchParams);
const rawPeriod = readStringParam(
body,
Copilot is powered by AI and may make mistakes. Always verify output.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 5, 2026

📝 Walkthrough

Walkthrough

Adds a changeset and a package script, integrates billing initialization into an existing sandbox scenario, and performs a large refactor of the billing mock implementation. BillingService is redesigned into reusable builders for plans, features, subscriptions, payment methods, checkouts, payments, and statements. MSW request handlers are extended with a comprehensive v1/billing/* surface, pagination and request utilities, and state wiring for per-payer subscriptions. The Clerk state setter signature is extended to accept initial billing data.

🚥 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(msw): Billing service mocks' clearly and accurately summarizes the main change—adding comprehensive billing service mocks to the MSW package.

✏️ 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: 1

🤖 Fix all issues with AI agents
In `@packages/msw/BillingService.ts`:
- Around line 29-469: Add unit tests that exercise the new BillingService
builders: cover createPlan (defaults and overrides), createDefaultPlans (tier
counts and slugs), resolvePlanAmount via
createSubscription/createSubscriptionItem (monthly vs annual),
createFreeTrialSubscription (trial dates and next_payment), createCheckout
(needs_payment_method and free_trial_ends_at behavior), and createPaymentAttempt
(paid vs failed statuses); import BillingService and assert shapes, amounts,
dates (relative to Date.now mock), and key fields like id, slug, fee,
annual_fee, subscription_items, next_payment, totals, and payment_method;
include tests for edge cases (free plan amount 0, non-recurring plans, overrides
passed into createSubscription/createCheckout) so the mock billing flows are
fully exercised before merging.

Comment on lines 29 to 469
export class BillingService {
private static createPaymentSources(): BillingPaymentSourceJSON[] {
return [
{
card_type: 'visa',
id: 'card_mock_4242',
is_default: true,
is_removable: true,
last4: '4242',
object: 'commerce_payment_method',
payment_method: 'card',
payment_type: 'card',
status: 'active',
wallet_type: null,
} as any,
];
private static createId(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}

private static createPlans(): BillingPlanJSON[] {
return [
{
amount: 999,
amount_formatted: '9.99',
annual_amount: 9900,
annual_amount_formatted: '99.00',
annual_fee: { amount: 9900, amount_formatted: '99.00', currency: 'usd', currency_symbol: '$' },
annual_monthly_amount: 825,
annual_monthly_amount_formatted: '8.25',
annual_monthly_fee: { amount: 825, amount_formatted: '8.25', currency: 'usd', currency_symbol: '$' },
avatar_url: '',
currency: 'usd',
currency_symbol: '$',
description: 'Basic plan with essential features',
features: [
{
avatar_url: '',
description: 'Feature 1',
id: 'feat_1',
name: 'Feature 1',
object: 'feature',
slug: 'feature-1',
},
{
avatar_url: '',
description: 'Feature 2',
id: 'feat_2',
name: 'Feature 2',
object: 'feature',
slug: 'feature-2',
},
{
avatar_url: '',
description: 'Feature 3',
id: 'feat_3',
name: 'Feature 3',
object: 'feature',
slug: 'feature-3',
},
],
fee: { amount: 999, amount_formatted: '9.99', currency: 'usd', currency_symbol: '$' },
for_payer_type: 'user',
free_trial_days: 14,
free_trial_enabled: true,
has_base_fee: true,
id: 'plan_basic_monthly',
is_default: false,
is_recurring: true,
name: 'Basic',
object: 'commerce_plan',
publicly_visible: true,
slug: 'basic',
},
];
private static slugify(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}

private static createSubscription(): BillingSubscriptionJSON {
const now = Date.now();
const thirtyDaysFromNow = now + 30 * 24 * 60 * 60 * 1000;
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;

private static createMoney(
amount: number,
currency: string = DEFAULT_CURRENCY,
currencySymbol: string = DEFAULT_CURRENCY_SYMBOL,
): BillingMoneyAmountJSON {
return {
active_at: thirtyDaysAgo,
created_at: thirtyDaysAgo,
eligible_for_free_trial: false,
id: 'sub_mock_active',
next_payment: {
amount: {
amount: 999,
amount_formatted: '9.99',
currency: 'usd',
currency_symbol: '$',
},
date: thirtyDaysFromNow,
} as any,
object: 'commerce_subscription',
past_due_at: null,
status: 'active',
subscription_items: [
{
amount: {
amount: 999,
amount_formatted: '9.99',
currency: 'usd',
currency_symbol: '$',
},
canceled_at: null,
created_at: thirtyDaysAgo,
id: 'subi_mock_basic',
is_free_trial: false,
object: 'commerce_subscription_item',
past_due_at: null,
payment_method_id: 'card_mock_4242',
period_end: thirtyDaysFromNow,
period_start: thirtyDaysAgo,
plan: this.createPlans()[0],
plan_period: 'month',
status: 'active',
upcoming_at: null,
updated_at: now,
},
] as any,
updated_at: now,
amount,
amount_formatted: (amount / 100).toFixed(2),
currency,
currency_symbol: currencySymbol,
};
}

private static createEligibleSubscription(): BillingSubscriptionJSON {
const now = Date.now();

private static createFeature(name: string, description: string, id: string): FeatureJSON {
return {
active_at: null,
created_at: now,
eligible_for_free_trial: true,
id: 'sub_mock_eligible',
next_payment: null,
object: 'commerce_subscription',
past_due_at: null,
status: 'inactive',
subscription_items: [],
updated_at: now,
} as unknown as BillingSubscriptionJSON;
object: 'feature',
id,
name,
description,
slug: this.slugify(name),
avatar_url: null,
};
}

private static createFreeTrialSubscription(): BillingSubscriptionJSON {
const now = Date.now();
const fourteenDaysFromNow = now + 14 * 24 * 60 * 60 * 1000;
static createPlan(overrides: Partial<BillingPlanJSON> = {}): BillingPlanJSON {
const name = overrides.name ?? 'Starter';
const slug = overrides.slug ?? this.slugify(name);
const fee = overrides.fee ?? this.createMoney(1200);
const annualFee =
overrides.annual_fee === undefined
? fee.amount > 0
? this.createMoney(Math.round(fee.amount * 10))
: null
: overrides.annual_fee;
const annualMonthlyFee =
overrides.annual_monthly_fee === undefined
? annualFee
? this.createMoney(Math.round(annualFee.amount / 12))
: null
: overrides.annual_monthly_fee;

return {
active_at: now,
created_at: now,
eligible_for_free_trial: false,
id: 'sub_mock_trial',
next_payment: {
amount: {
amount: 999,
amount_formatted: '9.99',
currency: 'usd',
currency_symbol: '$',
},
date: fourteenDaysFromNow,
},
object: 'commerce_subscription',
past_due_at: null,
status: 'trialing',
subscription_items: [
{
amount: {
amount: 0,
amount_formatted: '0.00',
currency: 'usd',
currency_symbol: '$',
},
canceled_at: null,
created_at: now,
id: 'subi_mock_trial_basic',
is_free_trial: true,
object: 'commerce_subscription_item',
past_due_at: null,
payment_method_id: null,
period_end: fourteenDaysFromNow,
period_start: now,
plan: this.createPlans()[0],
plan_period: 'trial',
status: 'trialing',
upcoming_at: fourteenDaysFromNow,
updated_at: now,
},
object: 'commerce_plan',
id: overrides.id ?? `plan_${overrides.for_payer_type ?? 'user'}_${slug}`,
name,
fee,
annual_fee: annualFee,
annual_monthly_fee: annualMonthlyFee,
description: overrides.description ?? `${name} plan`,
is_default: overrides.is_default ?? false,
is_recurring: overrides.is_recurring ?? fee.amount > 0,
has_base_fee: overrides.has_base_fee ?? fee.amount > 0,
for_payer_type: overrides.for_payer_type ?? 'user',
publicly_visible: overrides.publicly_visible ?? true,
slug,
avatar_url: overrides.avatar_url ?? null,
features: overrides.features ?? [
this.createFeature('Authentication', 'Email/password and social sign in', `${slug}_auth`),
this.createFeature('Session management', 'Active session controls and limits', `${slug}_sessions`),
],
updated_at: now,
} as unknown as BillingSubscriptionJSON;
free_trial_days: overrides.free_trial_days ?? (fee.amount > 0 ? 14 : null),
free_trial_enabled: overrides.free_trial_enabled ?? fee.amount > 0,
};
}

static getPaymentSources(
session: SessionResource | null,
user: UserResource | null,
): AuthCheckResult<{
data: BillingPaymentSourceJSON[];
response: { data: BillingPaymentSourceJSON[]; total_count: number };
total_count: number;
}> {
if (!session || !user) {
return { authorized: false, error: 'No active session', status: 401 };
}

const paymentSources = this.createPaymentSources();
static createDefaultPlans(): BillingPlanJSON[] {
const tiers = [
{ key: 'free', name: 'Free', description: 'Starter access for testing and development' },
{ key: 'bronze', name: 'Bronze', description: 'Entry paid tier for growing products' },
{ key: 'silver', name: 'Silver', description: 'Mid-tier plan for production workloads' },
{ key: 'gold', name: 'Gold', description: 'Premium tier for business-critical apps' },
] as const;

return {
authorized: true,
data: {
data: paymentSources,
response: {
data: paymentSources,
total_count: paymentSources.length,
},
total_count: paymentSources.length,
},
const amountsByPayer: Record<BillingPlanJSON['for_payer_type'], Record<(typeof tiers)[number]['key'], number>> = {
user: { free: 0, bronze: 1200, silver: 3200, gold: 7900 },
org: { free: 0, bronze: 2900, silver: 6900, gold: 14900 },
};
}

static initializePaymentSource(
session: SessionResource | null,
user: UserResource | null,
): AuthCheckResult<{ response: { client_secret: string; object: string; status: string } }> {
if (!session || !user) {
return { authorized: false, error: 'No active session', status: 401 };
const plans: BillingPlanJSON[] = [];

for (const payerType of ['user', 'org'] as const) {
for (const tier of tiers) {
const amount = amountsByPayer[payerType][tier.key];
const annualFee = amount > 0 ? this.createMoney(amount * 10) : null;
const annualMonthlyFee = annualFee ? this.createMoney(Math.round(annualFee.amount / 12)) : null;
const isFree = tier.key === 'free';
const planName = `${tier.name} ${payerType === 'org' ? 'Organization' : 'User'}`;
const baseSlug = `${tier.key}-${payerType}`;

plans.push(
this.createPlan({
id: `plan_${payerType}_${tier.key}`,
name: planName,
slug: baseSlug,
description: `${tier.description} (${payerType === 'org' ? 'organization' : 'user'} billing)`,
fee: this.createMoney(amount),
annual_fee: annualFee,
annual_monthly_fee: annualMonthlyFee,
for_payer_type: payerType,
is_default: isFree,
is_recurring: !isFree,
has_base_fee: !isFree,
free_trial_days: isFree ? null : 14,
free_trial_enabled: !isFree,
features: [
this.createFeature('Authentication', 'Standard authentication flows', `${baseSlug}_auth`),
this.createFeature(
'Members',
payerType === 'org' ? 'Organization membership controls' : 'User account management',
`${baseSlug}_members`,
),
this.createFeature(
'Support',
isFree ? 'Community support' : `${tier.name} plan support SLA`,
`${baseSlug}_support`,
),
],
}),
);
}
}

return {
authorized: true,
data: {
response: {
client_secret: 'mock_client_secret_' + Math.random().toString(36).substring(2, 15),
object: 'payment_intent',
status: 'requires_payment_method',
},
},
};
return plans;
}

static createPaymentSource(
session: SessionResource | null,
user: UserResource | null,
): AuthCheckResult<{ response: BillingPaymentSourceJSON }> {
if (!session || !user) {
return { authorized: false, error: 'No active session', status: 401 };
private static resolvePlanAmount(
plan: BillingPlanJSON,
planPeriod: BillingSubscriptionPlanPeriod,
): BillingMoneyAmountJSON {
if (planPeriod === 'annual') {
return plan.annual_fee ?? plan.fee;
}
return plan.fee;
}

static createSubscriptionItem(
plan: BillingPlanJSON,
overrides: Partial<BillingSubscriptionItemJSON> = {},
): BillingSubscriptionItemJSON {
const now = Date.now();
const itemPlan = overrides.plan ?? plan;
const planPeriod = overrides.plan_period ?? 'month';
const resolvedAmount = overrides.amount ?? this.resolvePlanAmount(itemPlan, planPeriod);
const defaultPeriodEnd =
resolvedAmount.amount === 0 && !itemPlan.is_recurring
? null
: now + (planPeriod === 'annual' ? 365 * DAY_IN_MS : 30 * DAY_IN_MS);

return {
authorized: true,
data: {
response: {
card_type: 'visa',
id: 'card_mock_' + Math.random().toString(36).substring(2, 9),
is_default: false,
is_removable: true,
last4: '4242',
object: 'commerce_payment_source',
payment_method: 'card',
payment_type: 'card',
status: 'active',
wallet_type: null,
} as any,
},
object: 'commerce_subscription_item',
id: overrides.id ?? this.createId('subi'),
amount: resolvedAmount,
credit: overrides.credit,
plan: itemPlan,
plan_period: planPeriod,
status: overrides.status ?? 'active',
created_at: overrides.created_at ?? now - DAY_IN_MS,
period_start: overrides.period_start ?? now - DAY_IN_MS,
period_end: overrides.period_end === undefined ? defaultPeriodEnd : overrides.period_end,
canceled_at: overrides.canceled_at ?? null,
past_due_at: overrides.past_due_at ?? null,
is_free_trial: overrides.is_free_trial ?? false,
};
}

static updatePaymentSource(
session: SessionResource | null,
user: UserResource | null,
): AuthCheckResult<{ response: { success: boolean } }> {
if (!session || !user) {
return { authorized: false, error: 'No active session', status: 401 };
}
static createSubscription(
plan: BillingPlanJSON,
overrides: Partial<BillingSubscriptionJSON> = {},
): BillingSubscriptionJSON {
const now = Date.now();
const firstOverrideItem =
Array.isArray(overrides.subscription_items) && overrides.subscription_items.length > 0
? overrides.subscription_items[0]
: undefined;
const planPeriod = firstOverrideItem?.plan_period ?? 'month';
const status = overrides.status ?? 'active';
const pastDueAt = overrides.past_due_at ?? (status === 'past_due' ? now - DAY_IN_MS : null);

return {
authorized: true,
data: {
response: {
success: true,
},
},
const baseItem = this.createSubscriptionItem(plan, {
id: `subi_${plan.id}`,
plan_period: planPeriod,
status: status === 'past_due' ? 'past_due' : 'active',
past_due_at: pastDueAt,
});

const hasSubscriptionItemsOverride = Object.prototype.hasOwnProperty.call(overrides, 'subscription_items');
const subscriptionItems: BillingSubscriptionJSON['subscription_items'] = hasSubscriptionItemsOverride
? (overrides.subscription_items ?? null)
: [baseItem];
const firstSubscriptionItem =
Array.isArray(subscriptionItems) && subscriptionItems.length > 0 ? subscriptionItems[0] : undefined;
const nextPaymentPlan = firstSubscriptionItem?.plan ?? plan;
const nextPaymentPeriod = firstSubscriptionItem?.plan_period ?? planPeriod;
const nextPaymentAmount =
firstSubscriptionItem?.amount ?? this.resolvePlanAmount(nextPaymentPlan, nextPaymentPeriod);
const defaultNextPayment =
nextPaymentAmount.amount > 0
? {
amount: nextPaymentAmount,
date: now + (nextPaymentPeriod === 'annual' ? 365 * DAY_IN_MS : 30 * DAY_IN_MS),
}
: undefined;

const subscription: BillingSubscriptionJSON = {
object: 'commerce_subscription',
id: overrides.id ?? `sub_${plan.id}`,
status,
created_at: overrides.created_at ?? now - DAY_IN_MS,
active_at: overrides.active_at ?? now - DAY_IN_MS,
updated_at: overrides.updated_at ?? now,
past_due_at: pastDueAt,
subscription_items: subscriptionItems,
eligible_for_free_trial: overrides.eligible_for_free_trial ?? false,
};
}

static deletePaymentSource(
session: SessionResource | null,
user: UserResource | null,
): AuthCheckResult<{ response: { deleted: boolean; id: string; object: string } }> {
if (!session || !user) {
return { authorized: false, error: 'No active session', status: 401 };
if (defaultNextPayment) {
subscription.next_payment = defaultNextPayment;
}

if ('next_payment' in overrides) {
subscription.next_payment = overrides.next_payment;
}

return subscription;
}

static createFreeTrialSubscription(plan: BillingPlanJSON): BillingSubscriptionJSON {
const now = Date.now();
const trialDays = plan.free_trial_days ?? 14;
const trialEndsAt = now + trialDays * DAY_IN_MS;
const postTrialAmount = this.resolvePlanAmount(plan, 'month');
const trialItem = this.createSubscriptionItem(plan, {
amount: this.createMoney(0, postTrialAmount.currency, postTrialAmount.currency_symbol),
plan_period: 'month',
period_start: now,
period_end: trialEndsAt,
status: 'active',
is_free_trial: true,
});

return this.createSubscription(plan, {
created_at: now,
active_at: now,
updated_at: now,
eligible_for_free_trial: false,
subscription_items: [trialItem],
next_payment:
postTrialAmount.amount > 0
? {
amount: postTrialAmount,
date: trialEndsAt,
}
: undefined,
});
}

static createPaymentMethod(overrides: Partial<BillingPaymentMethodJSON> = {}): BillingPaymentMethodJSON {
const now = Date.now();

return {
authorized: true,
data: {
response: {
deleted: true,
id: 'card_mock_deleted',
object: 'commerce_payment_source',
},
},
object: 'commerce_payment_method',
id: overrides.id ?? this.createId('pm'),
last4: overrides.last4 ?? '4242',
payment_type: overrides.payment_type ?? 'card',
card_type: overrides.card_type ?? 'visa',
is_default: overrides.is_default ?? false,
is_removable: overrides.is_removable ?? true,
status: overrides.status ?? 'active',
wallet_type: overrides.wallet_type ?? null,
expiry_year: overrides.expiry_year ?? 2030,
expiry_month: overrides.expiry_month ?? 1,
created_at: overrides.created_at ?? now - DAY_IN_MS,
updated_at: overrides.updated_at ?? now,
};
}

static getPlans() {
const plans = this.createPlans();
static createPayer(overrides: Partial<BillingPayerJSON> = {}): BillingPayerJSON {
const now = Date.now();

return {
data: plans,
response: {
data: plans,
total_count: plans.length,
},
total_count: plans.length,
object: 'commerce_payer',
id: overrides.id ?? this.createId('payer'),
created_at: overrides.created_at ?? now,
updated_at: overrides.updated_at ?? now,
image_url: overrides.image_url,
user_id: overrides.user_id ?? null,
email: overrides.email ?? null,
first_name: overrides.first_name ?? null,
last_name: overrides.last_name ?? null,
organization_id: overrides.organization_id ?? null,
organization_name: overrides.organization_name ?? null,
};
}

static getStatements() {
return {
data: [],
total_count: 0,
private static createCheckoutTotals(amount: BillingMoneyAmountJSON): BillingCheckoutTotalsJSON {
const tax = this.createMoney(0, amount.currency, amount.currency_symbol);
const totals: BillingCheckoutTotalsWithOptionalAccountCredit = {
grand_total: amount,
subtotal: amount,
tax_total: tax,
total_due_now: amount,
credit: null,
past_due: null,
total_due_after_free_trial: amount,
account_credit: null,
};
return totals;
}

static getSubscription(
session: SessionResource | null,
user: UserResource | null,
subscriptionOverride?: BillingSubscriptionJSON | null,
): AuthCheckResult<{ response: BillingSubscriptionJSON }> {
if (!session || !user) {
return { authorized: false, error: 'No active session', status: 401 };
}
static createCheckout(plan: BillingPlanJSON, overrides: Partial<BillingCheckoutJSON> = {}): BillingCheckoutJSON {
const now = Date.now();
const planPeriod = overrides.plan_period ?? 'month';
const planForCheckout = overrides.plan ?? plan;
const selectedAmount = this.resolvePlanAmount(planForCheckout, planPeriod);
const checkoutPlan: BillingPlanJSON = {
...planForCheckout,
fee: selectedAmount,
};

const subscription = subscriptionOverride ?? this.createEligibleSubscription();
const needsPaymentMethod =
overrides.needs_payment_method ?? (selectedAmount.amount > 0 && !overrides.payment_method);
const freeTrialEndsAt =
overrides.free_trial_ends_at ??
(planForCheckout.free_trial_enabled && planForCheckout.free_trial_days
? now + planForCheckout.free_trial_days * DAY_IN_MS
: undefined);

return {
authorized: true,
data: {
response: subscription,
},
const checkout: BillingCheckoutJSON = {
object: 'commerce_checkout',
id: overrides.id ?? this.createId('chk'),
external_client_secret: overrides.external_client_secret ?? `mock_checkout_secret_${this.createId('secret')}`,
external_gateway_id: overrides.external_gateway_id ?? 'stripe',
payment_method: overrides.payment_method,
plan: checkoutPlan,
plan_period: planPeriod,
plan_period_start: overrides.plan_period_start ?? now,
status: overrides.status ?? 'needs_confirmation',
totals: overrides.totals ?? this.createCheckoutTotals(selectedAmount),
is_immediate_plan_change: overrides.is_immediate_plan_change ?? true,
payer: overrides.payer ?? this.createPayer(),
needs_payment_method: needsPaymentMethod,
};

if (freeTrialEndsAt) {
checkout.free_trial_ends_at = freeTrialEndsAt;
}

return checkout;
}

static getSubscriptions() {
static createPaymentAttempt(plan: BillingPlanJSON, overrides: Partial<BillingPaymentJSON> = {}): BillingPaymentJSON {
const now = Date.now();
const status = overrides.status ?? 'paid';
const subscriptionItem =
overrides.subscription_item ??
this.createSubscriptionItem(plan, {
plan_period: 'month',
status: status === 'failed' ? 'past_due' : 'active',
});
const amount = overrides.amount ?? this.resolvePlanAmount(subscriptionItem.plan, subscriptionItem.plan_period);

return {
data: [],
total_count: 0,
object: 'commerce_payment',
id: overrides.id ?? this.createId('pay'),
amount,
paid_at: overrides.paid_at ?? (status === 'paid' ? now - DAY_IN_MS : null),
failed_at: overrides.failed_at ?? (status === 'failed' ? now - DAY_IN_MS : null),
updated_at: overrides.updated_at ?? now,
payment_method: overrides.payment_method ?? this.createPaymentMethod({ is_default: true }),
subscription_item: subscriptionItem,
charge_type: overrides.charge_type ?? 'recurring',
status,
};
}

static startFreeTrial(
session: SessionResource | null,
user: UserResource | null,
): AuthCheckResult<{ response: BillingSubscriptionJSON }> {
if (!session || !user) {
return { authorized: false, error: 'No active session', status: 401 };
}

const subscription = this.createFreeTrialSubscription();
static createStatement(plan: BillingPlanJSON, overrides: Partial<BillingStatementJSON> = {}): BillingStatementJSON {
const now = Date.now();
const payment = this.createPaymentAttempt(plan);
const totals = this.createCheckoutTotals(payment.amount);

return {
authorized: true,
data: {
response: subscription,
object: 'commerce_statement',
id: overrides.id ?? this.createId('stmt'),
status: overrides.status ?? 'closed',
timestamp: overrides.timestamp ?? now,
groups: overrides.groups ?? [
{
object: 'commerce_statement_group',
id: this.createId('stmtgrp'),
timestamp: now,
items: [payment],
},
],
totals: overrides.totals ?? {
grand_total: totals.grand_total,
subtotal: totals.subtotal,
tax_total: totals.tax_total,
},
};
}

static createDefaultPaymentMethods(): BillingPaymentMethodJSON[] {
return [
this.createPaymentMethod({
id: 'pm_mock_4242',
last4: '4242',
card_type: 'visa',
is_default: true,
is_removable: true,
}),
];
}

static createInitializedPaymentMethod(
overrides: Partial<BillingInitializedPaymentMethodJSON> = {},
): BillingInitializedPaymentMethodJSON {
const response: BillingInitializedPaymentMethodWithOptionalId = {
id: overrides.id ?? this.createId('pmi'),
object: 'commerce_payment_method_initialize',
external_client_secret:
overrides.external_client_secret ?? `mock_client_secret_${Math.random().toString(36).slice(2, 15)}`,
external_gateway_id: overrides.external_gateway_id ?? 'stripe',
payment_method_order: overrides.payment_method_order ?? ['card'],
};

return response;
}
}
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

Add test coverage for the new billing mocks before merge.

I don’t see any tests added/updated in this PR for the new billing builders and handlers. Please add coverage (or point to existing tests) so the mock billing flows are exercised before merging.

As per coding guidelines, **/*: If there are no tests added or modified as part of the PR, please suggest that tests be added to cover the changes.

🧰 Tools
🪛 GitHub Check: CodeQL

[failure] 35-39: Polynomial regular expression used on uncontrolled data
This regular expression that depends on library input may run slow on strings with many repetitions of '-'.
This regular expression that depends on library input may run slow on strings with many repetitions of '-'.
This regular expression that depends on library input may run slow on strings with many repetitions of '-'.


[failure] 372-372: Insecure randomness
This uses a cryptographically insecure random number generated at Math.random() in a security context.


[failure] 462-462: Insecure randomness
This uses a cryptographically insecure random number generated at Math.random() in a security context.

🤖 Prompt for AI Agents
In `@packages/msw/BillingService.ts` around lines 29 - 469, Add unit tests that
exercise the new BillingService builders: cover createPlan (defaults and
overrides), createDefaultPlans (tier counts and slugs), resolvePlanAmount via
createSubscription/createSubscriptionItem (monthly vs annual),
createFreeTrialSubscription (trial dates and next_payment), createCheckout
(needs_payment_method and free_trial_ends_at behavior), and createPaymentAttempt
(paid vs failed statuses); import BillingService and assert shapes, amounts,
dates (relative to Date.now mock), and key fields like id, slug, fee,
annual_fee, subscription_items, next_payment, totals, and payment_method;
include tests for edge cases (free plan amount 0, non-recurring plans, overrides
passed into createSubscription/createCheckout) so the mock billing flows are
fully exercised before merging.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 5, 2026

Open in StackBlitz

@clerk/agent-toolkit

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

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/dev-cli

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: 34eab63

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