Conversation
🦋 Changeset detectedLatest commit: 34eab63 The changes in this PR will be included in the next version bump. This PR includes changesets to release 0 packagesWhen 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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| return value | ||
| .toLowerCase() | ||
| .trim() | ||
| .replace(/[^a-z0-9]+/g, '-') | ||
| .replace(/^-+|-+$/g, ''); |
Check failure
Code scanning / CodeQL
Polynomial regular expression used on uncontrolled data High
| 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
Show autofix suggestion
Hide autofix suggestion
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
cryptomodule at the top ofpackages/msw/BillingService.ts:import * as crypto from 'crypto'; - Replace the body of
BillingService.createIdso that instead of usingMath.random(), it usescrypto.randomBytesto 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.
| @@ -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 { |
| 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
Show autofix suggestion
Hide autofix suggestion
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
cryptomodule at the top ofpackages/msw/BillingService.ts. - Introduce a small helper method in
BillingService, e.g.private static createRandomIdSegment(length: number): string, that usescrypto.randomBytesto 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:
- Add
import crypto from 'crypto';at the top (no external deps). - Add a helper in
BillingServicenear the other statics. - 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.
| @@ -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'], | ||
| }; |
| payer: createBillingPayer(), | ||
| payment_method: paymentMethod, | ||
| needs_payment_method: !paymentMethod, | ||
| external_client_secret: `mock_checkout_secret_${checkoutId}`, |
Check failure
Code scanning / CodeQL
Insecure randomness High
Show autofix suggestion
Hide autofix suggestion
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
randomUUIDfrom Node’scryptomodule at the top ofpackages/msw/request-handlers.ts. - Change the line that sets
checkoutIdso it no longer usesMath.random()and instead callsrandomUUID(), maintaining the same string format with thechk_mock_prefix.
No additional methods or helpers are required beyond the new import.
| @@ -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, |
📝 WalkthroughWalkthroughAdds 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)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
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.
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
@clerk/agent-toolkit
@clerk/astro
@clerk/backend
@clerk/chrome-extension
@clerk/clerk-js
@clerk/dev-cli
@clerk/expo
@clerk/expo-passkeys
@clerk/express
@clerk/fastify
@clerk/localizations
@clerk/nextjs
@clerk/nuxt
@clerk/react
@clerk/react-router
@clerk/shared
@clerk/tanstack-react-start
@clerk/testing
@clerk/ui
@clerk/upgrade
@clerk/vue
commit: |
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 testruns as expected.pnpm buildruns as expected.Type of change
Summary by CodeRabbit
New Features
Tests
Chores