diff --git a/.changeset/soft-trains-grin.md b/.changeset/soft-trains-grin.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/soft-trains-grin.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/clerk-js/sandbox/scenarios/user-button-signed-in.ts b/packages/clerk-js/sandbox/scenarios/user-button-signed-in.ts index 20812fc3baa..e56d70606dc 100644 --- a/packages/clerk-js/sandbox/scenarios/user-button-signed-in.ts +++ b/packages/clerk-js/sandbox/scenarios/user-button-signed-in.ts @@ -1,4 +1,5 @@ import { + BillingService, clerkHandlers, EnvironmentService, SessionService, @@ -10,11 +11,17 @@ import { export function UserButtonSignedIn(): MockScenario { const user = UserService.create(); const session = SessionService.create(user); + const plans = BillingService.createDefaultPlans(); + const subscription = BillingService.createSubscription(plans[1]); setClerkState({ environment: EnvironmentService.MULTI_SESSION, session, user, + billing: { + plans, + subscription, + }, }); return { diff --git a/packages/msw/BillingService.ts b/packages/msw/BillingService.ts index 9f13225b7cc..6e90b7d02ba 100644 --- a/packages/msw/BillingService.ts +++ b/packages/msw/BillingService.ts @@ -1,377 +1,469 @@ import type { - BillingPaymentSourceJSON, + BillingCheckoutJSON, + BillingCheckoutTotalsJSON, + BillingInitializedPaymentMethodJSON, + BillingMoneyAmountJSON, + BillingPaymentJSON, + BillingPaymentMethodJSON, + BillingPayerJSON, BillingPlanJSON, + BillingStatementJSON, + BillingSubscriptionItemJSON, BillingSubscriptionJSON, - SessionResource, - UserResource, + BillingSubscriptionPlanPeriod, + FeatureJSON, } from '@clerk/shared/types'; -type AuthCheckResult = { authorized: true; data: T } | { authorized: false; error: string; status: number }; +type BillingCheckoutTotalsWithOptionalAccountCredit = BillingCheckoutTotalsJSON & { + account_credit?: BillingMoneyAmountJSON | null; +}; + +type BillingInitializedPaymentMethodWithOptionalId = BillingInitializedPaymentMethodJSON & { + id?: string; +}; + +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const DEFAULT_CURRENCY = 'usd'; +const DEFAULT_CURRENCY_SYMBOL = '$'; 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 { + 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> = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } } diff --git a/packages/msw/package.json b/packages/msw/package.json index 4c75a22e657..50e0ea9d520 100644 --- a/packages/msw/package.json +++ b/packages/msw/package.json @@ -10,6 +10,9 @@ "default": "./index.ts" } }, + "scripts": { + "type-check": "tsc --noEmit" + }, "dependencies": { "@clerk/shared": "workspace:^", "msw": "2.11.3" diff --git a/packages/msw/request-handlers.ts b/packages/msw/request-handlers.ts index 60815c8f884..3351ab3bf00 100644 --- a/packages/msw/request-handlers.ts +++ b/packages/msw/request-handlers.ts @@ -1,6 +1,9 @@ import { http, HttpResponse } from 'msw'; import type { + BillingPaymentMethodJSON, + BillingPayerJSON, + BillingPlanJSON, BillingSubscriptionJSON, OrganizationMembershipResource, OrganizationResource, @@ -166,9 +169,226 @@ let currentOrganization: OrganizationResource | null = null; let currentMembership: OrganizationMembershipResource | null = null; let currentInvitations: any[] = []; let currentEnvironment: EnvironmentPreset = EnvironmentService.MULTI_SESSION; +let currentBillingPlans: BillingPlanJSON[] = BillingService.createDefaultPlans(); let currentSubscription: BillingSubscriptionJSON | null = null; +let currentOrganizationSubscription: BillingSubscriptionJSON | null = null; + +function getSubscriptionPayerType( + subscription: BillingSubscriptionJSON | null | undefined, +): BillingPlanJSON['for_payer_type'] | null { + return subscription?.subscription_items?.[0]?.plan?.for_payer_type ?? null; +} + +function getBillingPlans(): BillingPlanJSON[] { + if (currentBillingPlans.length > 0) { + return currentBillingPlans; + } + return BillingService.createDefaultPlans(); +} + +function getBillingPlansForPayerType(payerType: BillingPlanJSON['for_payer_type']): BillingPlanJSON[] { + const plans = getBillingPlans().filter(plan => plan.for_payer_type === payerType); + if (plans.length > 0) { + return plans; + } + return BillingService.createDefaultPlans().filter(plan => plan.for_payer_type === payerType); +} + +function getDefaultBillingPlan(payerType: BillingPlanJSON['for_payer_type']): BillingPlanJSON { + const plans = getBillingPlansForPayerType(payerType); + return ( + plans.find(plan => plan.is_default) ?? + plans[0] ?? + BillingService.createPlan({ + for_payer_type: payerType, + id: `plan_${payerType}_fallback`, + name: payerType === 'org' ? 'Organization Plan' : 'User Plan', + slug: `${payerType}-fallback`, + }) + ); +} + +function resolveBillingPlan(payerType: BillingPlanJSON['for_payer_type'], planId?: string | null): BillingPlanJSON { + const plans = getBillingPlansForPayerType(payerType); + if (planId) { + const matchingPlan = plans.find(plan => plan.id === planId); + if (matchingPlan) { + return matchingPlan; + } + } + return getDefaultBillingPlan(payerType); +} + +function getCurrentUserEmail(): string | null { + const safeUser = currentUser as any; + return ( + safeUser?.primaryEmailAddress?.emailAddress ?? + safeUser?.emailAddresses?.[0]?.emailAddress ?? + (safeUser?.id ? `${safeUser.id}@example.com` : null) + ); +} + +function createBillingPayer(orgId?: string): BillingPayerJSON { + const safeUser = currentUser as any; + const payerType = orgId ? 'org' : 'user'; + + return BillingService.createPayer({ + id: orgId ? `payer_org_${orgId}` : `payer_user_${currentUser?.id ?? 'mock'}`, + user_id: currentUser?.id ?? null, + email: getCurrentUserEmail(), + first_name: safeUser?.firstName ?? null, + last_name: safeUser?.lastName ?? null, + organization_id: orgId ?? null, + organization_name: payerType === 'org' ? ((currentOrganization as any)?.name ?? null) : null, + }); +} + +function getStoredSubscriptionForPayerType( + payerType: BillingPlanJSON['for_payer_type'], +): BillingSubscriptionJSON | null { + if (payerType === 'org') { + return currentOrganizationSubscription; + } + return currentSubscription; +} + +function setStoredSubscriptionForPayerType( + payerType: BillingPlanJSON['for_payer_type'], + subscription: BillingSubscriptionJSON | null, +) { + if (payerType === 'org') { + currentOrganizationSubscription = subscription; + return; + } + currentSubscription = subscription; +} + +function getBillingSubscriptionForPayerType(payerType: BillingPlanJSON['for_payer_type']): BillingSubscriptionJSON { + const storedSubscription = getStoredSubscriptionForPayerType(payerType); + if (storedSubscription && getSubscriptionPayerType(storedSubscription) === payerType) { + return storedSubscription; + } + const generatedSubscription = BillingService.createSubscription(getDefaultBillingPlan(payerType)); + setStoredSubscriptionForPayerType(payerType, generatedSubscription); + return generatedSubscription; +} + +function getNestedParams(body: Record): Record | null { + const nested = body.params; + if (nested && typeof nested === 'object' && !Array.isArray(nested)) { + return nested as Record; + } + return null; +} + +function readStringParam( + body: Record, + keys: string[], + searchParams: URLSearchParams, +): string | undefined { + const nested = getNestedParams(body); + + for (const key of keys) { + const value = body[key]; + if (typeof value === 'string' && value.length > 0) { + return value; + } + + const loweredKey = key.toLowerCase(); + const loweredValue = body[loweredKey]; + if (typeof loweredValue === 'string' && loweredValue.length > 0) { + return loweredValue; + } + + const nestedValue = nested?.[key]; + if (typeof nestedValue === 'string' && nestedValue.length > 0) { + return nestedValue; + } + + const nestedLoweredValue = nested?.[loweredKey]; + if (typeof nestedLoweredValue === 'string' && nestedLoweredValue.length > 0) { + return nestedLoweredValue; + } + } + + for (const key of keys) { + const fromQuery = searchParams.get(key) ?? searchParams.get(key.toLowerCase()); + if (fromQuery) { + return fromQuery; + } + } + + return undefined; +} + +async function parseRequestBodyAsRecord(request: Request): Promise> { + const text = await request.text(); + if (!text) { + return {}; + } + + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // noop + } + + return parseUrlEncodedBody(text); +} + +function parsePagination(searchParams: URLSearchParams): { limit?: number; offset?: number } { + const parseValue = (value: string | null): number | undefined => { + if (!value) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; + }; + + return { + limit: parseValue(searchParams.get('limit')), + offset: parseValue(searchParams.get('offset')), + }; +} + +function paginateCollection(items: T[], limit?: number, offset?: number): { data: T[]; total_count: number } { + const safeOffset = typeof offset === 'number' && offset >= 0 ? offset : 0; + const safeLimit = typeof limit === 'number' && limit >= 0 ? limit : items.length; + return { + data: items.slice(safeOffset, safeOffset + safeLimit), + total_count: items.length, + }; +} + +function normalizePlanPeriod(value: string | null | undefined): 'month' | 'annual' { + const normalized = (value || '').toLowerCase(); + if (['annual', 'year', 'yearly', 'annually'].includes(normalized)) { + return 'annual'; + } + return 'month'; +} + +function createUnauthorizedResponse() { + return createNoStoreResponse({ error: 'No active session' }, { status: 401 }); +} + +function getDefaultPaymentMethods(): BillingPaymentMethodJSON[] { + return BillingService.createDefaultPaymentMethods(); +} + +function getPrimaryPaymentMethod(): BillingPaymentMethodJSON | undefined { + return getDefaultPaymentMethods()[0]; +} export function setClerkState(state: { + billing?: { + plans: BillingPlanJSON[]; + subscription: BillingSubscriptionJSON; + }; environment?: EnvironmentPreset; instance?: EnvironmentPreset; membership?: OrganizationMembershipResource | null; @@ -181,7 +401,17 @@ export function setClerkState(state: { currentOrganization = state.organization ?? null; currentMembership = state.membership ?? null; currentInvitations = []; + currentBillingPlans = state.billing?.plans ?? BillingService.createDefaultPlans(); currentSubscription = null; + currentOrganizationSubscription = null; + if (state.billing?.subscription) { + const payerType = getSubscriptionPayerType(state.billing.subscription); + if (payerType === 'org') { + currentOrganizationSubscription = state.billing.subscription; + } else { + currentSubscription = state.billing.subscription; + } + } SignUpService.reset(); SignInService.reset(); @@ -1418,14 +1648,371 @@ export const clerkHandlers = [ }); }), + // Billing namespace endpoints + http.get('*/v1/billing/plans', ({ request }) => { + const url = new URL(request.url); + const payerTypeParam = url.searchParams.get('payer_type'); + const payerType = + payerTypeParam === 'org' || payerTypeParam === 'user' + ? (payerTypeParam as BillingPlanJSON['for_payer_type']) + : undefined; + const { limit, offset } = parsePagination(url.searchParams); + const plans = payerType ? getBillingPlansForPayerType(payerType) : getBillingPlans(); + return createNoStoreResponse(paginateCollection(plans, limit, offset)); + }), + + http.get('*/v1/billing/plans/:id', ({ params }) => { + const planId = params.id as string; + const plan = getBillingPlans().find(item => item.id === planId); + + if (!plan) { + return createNoStoreResponse({ error: 'Plan not found' }, { status: 404 }); + } + + return createNoStoreResponse(plan); + }), + + http.get('*/v1/me/billing/subscription', () => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + return createNoStoreResponse({ response: getBillingSubscriptionForPayerType('user') }); + }), + + http.get('*/v1/organizations/:orgId/billing/subscription', () => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + return createNoStoreResponse({ response: getBillingSubscriptionForPayerType('org') }); + }), + + http.get('*/v1/me/billing/statements', ({ request }) => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + const url = new URL(request.url); + const { limit, offset } = parsePagination(url.searchParams); + const subscription = getBillingSubscriptionForPayerType('user'); + const plan = subscription.subscription_items?.[0]?.plan ?? getDefaultBillingPlan('user'); + const response = paginateCollection([BillingService.createStatement(plan)], limit, offset); + + return createNoStoreResponse({ response }); + }), + + http.get('*/v1/organizations/:orgId/billing/statements', ({ request }) => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + const url = new URL(request.url); + const { limit, offset } = parsePagination(url.searchParams); + const subscription = getBillingSubscriptionForPayerType('org'); + const plan = subscription.subscription_items?.[0]?.plan ?? getDefaultBillingPlan('org'); + const response = paginateCollection([BillingService.createStatement(plan)], limit, offset); + + return createNoStoreResponse({ response }); + }), + + http.get('*/v1/me/billing/statements/:id', ({ params }) => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + const subscription = getBillingSubscriptionForPayerType('user'); + const plan = subscription.subscription_items?.[0]?.plan ?? getDefaultBillingPlan('user'); + const statement = BillingService.createStatement(plan, { id: params.id as string }); + + return createNoStoreResponse({ response: statement }); + }), + + http.get('*/v1/organizations/:orgId/billing/statements/:id', ({ params }) => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + const subscription = getBillingSubscriptionForPayerType('org'); + const plan = subscription.subscription_items?.[0]?.plan ?? getDefaultBillingPlan('org'); + const statement = BillingService.createStatement(plan, { id: params.id as string }); + + return createNoStoreResponse({ response: statement }); + }), + + http.get('*/v1/me/billing/payment_attempts', ({ request }) => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + const url = new URL(request.url); + const { limit, offset } = parsePagination(url.searchParams); + const subscription = getBillingSubscriptionForPayerType('user'); + const plan = subscription.subscription_items?.[0]?.plan ?? getDefaultBillingPlan('user'); + + return createNoStoreResponse(paginateCollection([BillingService.createPaymentAttempt(plan)], limit, offset)); + }), + + http.get('*/v1/organizations/:orgId/billing/payment_attempts', ({ request }) => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + const url = new URL(request.url); + const { limit, offset } = parsePagination(url.searchParams); + const subscription = getBillingSubscriptionForPayerType('org'); + const plan = subscription.subscription_items?.[0]?.plan ?? getDefaultBillingPlan('org'); + + return createNoStoreResponse(paginateCollection([BillingService.createPaymentAttempt(plan)], limit, offset)); + }), + + http.get('*/v1/me/billing/payment_attempts/:id', ({ params }) => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + const subscription = getBillingSubscriptionForPayerType('user'); + const plan = subscription.subscription_items?.[0]?.plan ?? getDefaultBillingPlan('user'); + + return createNoStoreResponse(BillingService.createPaymentAttempt(plan, { id: params.id as string })); + }), + + http.get('*/v1/organizations/:orgId/billing/payment_attempts/:id', ({ params }) => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + const subscription = getBillingSubscriptionForPayerType('org'); + const plan = subscription.subscription_items?.[0]?.plan ?? getDefaultBillingPlan('org'); + + return createNoStoreResponse(BillingService.createPaymentAttempt(plan, { id: params.id as string })); + }), + + http.get('*/v1/me/billing/payment_methods', ({ request }) => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + const url = new URL(request.url); + const { limit, offset } = parsePagination(url.searchParams); + const paginated = paginateCollection(getDefaultPaymentMethods(), limit, offset); + + return createNoStoreResponse({ + response: paginated, + }); + }), + + http.get('*/v1/organizations/:orgId/billing/payment_methods', ({ request }) => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + const url = new URL(request.url); + const { limit, offset } = parsePagination(url.searchParams); + const paginated = paginateCollection(getDefaultPaymentMethods(), limit, offset); + + return createNoStoreResponse({ + response: paginated, + }); + }), + + http.post('*/v1/me/billing/payment_methods/initialize', () => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + return createNoStoreResponse({ + response: BillingService.createInitializedPaymentMethod(), + }); + }), + + http.post('*/v1/organizations/:orgId/billing/payment_methods/initialize', () => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + return createNoStoreResponse({ + response: BillingService.createInitializedPaymentMethod(), + }); + }), + + http.post('*/v1/me/billing/payment_methods', async ({ request }) => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + try { + await request.text(); + } catch { + // ignore body in mock mode + } + + return createNoStoreResponse({ + response: BillingService.createPaymentMethod({ + card_type: 'visa', + last4: '4242', + }), + }); + }), + + http.post('*/v1/organizations/:orgId/billing/payment_methods', async ({ request }) => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + try { + await request.text(); + } catch { + // ignore body in mock mode + } + + return createNoStoreResponse({ + response: BillingService.createPaymentMethod({ + card_type: 'visa', + last4: '4242', + }), + }); + }), + + http.post('*/v1/me/billing/checkouts', async ({ request }) => { + if (!currentSession || !currentUser) { + return createNoStoreResponse({ error: 'No active session' }, { status: 401 }); + } + + const url = new URL(request.url); + const body = await parseRequestBodyAsRecord(request); + const planId = readStringParam(body, ['plan_id', 'planId', 'plan'], url.searchParams); + const rawPlanPeriod = readStringParam( + body, + ['plan_period', 'planPeriod', 'interval', 'billing_interval', 'billingInterval', 'period'], + url.searchParams, + ); + const planPeriod = normalizePlanPeriod(rawPlanPeriod); + const plan = resolveBillingPlan('user', planId); + const paymentMethod = getPrimaryPaymentMethod(); + const checkout = BillingService.createCheckout(plan, { + plan_period: planPeriod, + payer: createBillingPayer(), + payment_method: paymentMethod, + needs_payment_method: !paymentMethod, + }); + + return createNoStoreResponse({ response: checkout }); + }), + + http.post('*/v1/organizations/:orgId/billing/checkouts', async ({ params, request }) => { + if (!currentSession || !currentUser) { + return createNoStoreResponse({ error: 'No active session' }, { status: 401 }); + } + + const url = new URL(request.url); + const body = await parseRequestBodyAsRecord(request); + const planId = readStringParam(body, ['plan_id', 'planId', 'plan'], url.searchParams); + const rawPlanPeriod = readStringParam( + body, + ['plan_period', 'planPeriod', 'interval', 'billing_interval', 'billingInterval', 'period'], + url.searchParams, + ); + const planPeriod = normalizePlanPeriod(rawPlanPeriod); + const plan = resolveBillingPlan('org', planId); + const paymentMethod = getPrimaryPaymentMethod(); + const checkout = BillingService.createCheckout(plan, { + plan_period: planPeriod, + payer: createBillingPayer(params.orgId as string), + payment_method: paymentMethod, + needs_payment_method: !paymentMethod, + }); + + return createNoStoreResponse({ response: checkout }); + }), + + http.patch('*/v1/me/billing/checkouts/:checkoutId/confirm', async ({ params, request }) => { + if (!currentSession || !currentUser) { + return createNoStoreResponse({ error: 'No active session' }, { status: 401 }); + } + + const url = new URL(request.url); + const body = await parseRequestBodyAsRecord(request); + const planId = readStringParam(body, ['plan_id', 'planId', 'plan'], url.searchParams); + const rawPlanPeriod = readStringParam( + body, + ['plan_period', 'planPeriod', 'interval', 'billing_interval', 'billingInterval', 'period'], + url.searchParams, + ); + const planPeriod = normalizePlanPeriod(rawPlanPeriod); + const plan = resolveBillingPlan('user', planId); + const paymentMethod = getPrimaryPaymentMethod(); + const checkout = BillingService.createCheckout(plan, { + id: params.checkoutId as string, + plan_period: planPeriod, + status: 'completed', + payer: createBillingPayer(), + payment_method: paymentMethod, + needs_payment_method: !paymentMethod, + }); + + setStoredSubscriptionForPayerType( + 'user', + BillingService.createSubscription(plan, { + subscription_items: [ + BillingService.createSubscriptionItem(plan, { + plan_period: planPeriod, + }), + ], + }), + ); + + return createNoStoreResponse({ response: checkout }); + }), + + http.patch('*/v1/organizations/:orgId/billing/checkouts/:checkoutId/confirm', async ({ params, request }) => { + if (!currentSession || !currentUser) { + return createNoStoreResponse({ error: 'No active session' }, { status: 401 }); + } + + const url = new URL(request.url); + const body = await parseRequestBodyAsRecord(request); + const planId = readStringParam(body, ['plan_id', 'planId', 'plan'], url.searchParams); + const rawPlanPeriod = readStringParam( + body, + ['plan_period', 'planPeriod', 'interval', 'billing_interval', 'billingInterval', 'period'], + url.searchParams, + ); + const planPeriod = normalizePlanPeriod(rawPlanPeriod); + const plan = resolveBillingPlan('org', planId); + const paymentMethod = getPrimaryPaymentMethod(); + const checkout = BillingService.createCheckout(plan, { + id: params.checkoutId as string, + plan_period: planPeriod, + status: 'completed', + payer: createBillingPayer(params.orgId as string), + payment_method: paymentMethod, + needs_payment_method: !paymentMethod, + }); + + setStoredSubscriptionForPayerType( + 'org', + BillingService.createSubscription(plan, { + subscription_items: [ + BillingService.createSubscriptionItem(plan, { + plan_period: planPeriod, + }), + ], + }), + ); + + return createNoStoreResponse({ response: checkout }); + }), + // Commerce endpoints - Payment methods http.get('https://*.clerk.accounts.dev/v1/me/commerce/payment_methods', () => { - const result = BillingService.getPaymentSources(currentSession, currentUser); - if (!result.authorized) { - return createNoStoreResponse({ error: result.error }, { status: result.status }); + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); } - const data = result.data.data ?? result.data.response.data ?? []; - const total = result.data.total_count ?? result.data.response.total_count ?? data.length; + + const data = getDefaultPaymentMethods(); + const total = data.length; + return createNoStoreResponse({ data, response: { @@ -1437,195 +2024,100 @@ export const clerkHandlers = [ }), http.post('https://*.clerk.accounts.dev/v1/me/commerce/payment_methods/initialize', () => { - const result = BillingService.initializePaymentSource(currentSession, currentUser); - if (!result.authorized) { - return createNoStoreResponse({ error: result.error }, { status: result.status }); + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); } - return createNoStoreResponse(result.data); + + return createNoStoreResponse({ + response: BillingService.createInitializedPaymentMethod(), + }); }), http.post('https://*.clerk.accounts.dev/v1/me/commerce/payment_methods', () => { - const result = BillingService.createPaymentSource(currentSession, currentUser); - if (!result.authorized) { - return createNoStoreResponse({ error: result.error }, { status: result.status }); + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); } - return createNoStoreResponse(result.data); + + return createNoStoreResponse({ + response: BillingService.createPaymentMethod({ + card_type: 'visa', + last4: '4242', + }), + }); }), http.patch('https://*.clerk.accounts.dev/v1/me/commerce/payment_methods/:id', () => { - const result = BillingService.updatePaymentSource(currentSession, currentUser); - if (!result.authorized) { - return createNoStoreResponse({ error: result.error }, { status: result.status }); + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); } - return createNoStoreResponse(result.data); - }), - http.delete('https://*.clerk.accounts.dev/v1/me/commerce/payment_methods/:id', () => { - const result = BillingService.deletePaymentSource(currentSession, currentUser); - if (!result.authorized) { - return createNoStoreResponse({ error: result.error }, { status: result.status }); - } - return createNoStoreResponse(result.data); + return createNoStoreResponse({ + response: { + success: true, + }, + }); }), - http.post('https://*.clerk.accounts.dev/v1/me/commerce/checkouts', async ({ request }) => { + http.delete('https://*.clerk.accounts.dev/v1/me/commerce/payment_methods/:id', ({ params }) => { if (!currentSession || !currentUser) { - return createNoStoreResponse({ error: 'No active session' }, { status: 401 }); + return createUnauthorizedResponse(); } - let body: Record = {}; - let formBody: URLSearchParams | null = null; + return createNoStoreResponse({ + response: { + deleted: true, + id: (params.id as string) ?? 'pm_mock_deleted', + object: 'commerce_payment_method', + }, + }); + }), - // Read as text first (always works), then parse - const text = await request.text(); - if (text) { - try { - body = JSON.parse(text); - } catch { - formBody = new URLSearchParams(text); - formBody.forEach((value, key) => { - body[key] = value; - }); - } + http.post('https://*.clerk.accounts.dev/v1/me/commerce/checkouts', async ({ request }) => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); } + const url = new URL(request.url); + const body = await parseRequestBodyAsRecord(request); const checkoutId = `chk_mock_${Math.random().toString(36).slice(2, 10)}`; - const paymentIntentId = `pi_mock_${Math.random().toString(36).slice(2, 10)}`; - const plansResponse = BillingService.getPlans(); - const plans = plansResponse.response?.data ?? plansResponse.data ?? []; - const paymentSourcesResult = BillingService.getPaymentSources(currentSession, currentUser); - const paymentMethods = paymentSourcesResult.authorized - ? (paymentSourcesResult.data.data ?? paymentSourcesResult.data.response.data ?? []) - : []; - const normalizePlan = (input: any) => { - const safeArray = (value: any) => (Array.isArray(value) ? value : []); - const plan = - input ?? - ({ - annual_fee: null, - annual_monthly_fee: null, - avatar_url: '', - description: 'Mock plan', - 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_mock_default', - is_default: true, - is_recurring: true, - name: 'Mock Plan', - object: 'commerce_plan', - publicly_visible: true, - slug: 'mock-plan', - } as any); - - return { - ...plan, - annual_fee: plan.annual_fee ?? null, - annual_monthly_fee: plan.annual_monthly_fee ?? null, - avatar_url: plan.avatar_url ?? '', - description: plan.description ?? null, - features: safeArray((plan as any).features), - free_trial_days: plan.free_trial_days ?? null, - free_trial_enabled: plan.free_trial_enabled ?? false, - has_base_fee: plan.has_base_fee ?? false, - for_payer_type: plan.for_payer_type ?? 'user', - publicly_visible: plan.publicly_visible ?? true, - }; - }; - if (plans.length === 0) { - plans.push(normalizePlan(null)); - } - const urlParams = new URL(request.url).searchParams; - const getParam = (key: string) => { - const lower = key.toLowerCase(); - return ( - body[key] ?? - body?.params?.[key] ?? - body[lower] ?? - body?.params?.[lower] ?? - formBody?.get(key) ?? - formBody?.get(lower) ?? - urlParams.get(key) ?? - urlParams.get(lower) - ); - }; - - const preferredPlanId = getParam('plan_id') || getParam('planId') || getParam('plan') || getParam('planId'); - const rawPeriod = - getParam('plan_period') || - getParam('planPeriod') || - getParam('interval') || - getParam('billing_interval') || - getParam('billingInterval') || - getParam('billing_period') || - getParam('billingPeriod') || - getParam('cycle') || - getParam('billing_cycle') || - getParam('billingCycle') || - getParam('period'); - const normalizePeriod = (value: any): 'month' | 'annual' => { - const v = typeof value === 'string' ? value.toLowerCase() : ''; - if (v === 'month' || v === 'monthly') { - return 'month'; - } - if (['annual', 'year', 'yearly', 'annually'].includes(v)) { - return 'annual'; - } - // honor explicit values only; fall back to month when absent/unknown - return 'month'; - }; - const planPeriod: 'month' | 'annual' = rawPeriod ? normalizePeriod(rawPeriod) : 'month'; - const plan = plans.find(item => item.id === preferredPlanId) ?? plans[0]; - const normalizedPlan = normalizePlan(plan); - const now = Date.now(); - const needsPaymentMethod = paymentMethods.length === 0; - const payer = { - created_at: now, - email: (currentUser as any).emailAddresses?.[0]?.emailAddress ?? `${currentUser?.id}@example.com`, - first_name: (currentUser as any).firstName ?? null, - id: `payer_${currentUser?.id ?? 'mock'}`, - last_name: (currentUser as any).lastName ?? null, - object: 'commerce_payer', - organization_id: null, - organization_name: null, - updated_at: now, - user_id: currentUser?.id ?? null, - }; - const selectedFee = - planPeriod === 'annual' ? (normalizedPlan.annual_fee ?? normalizedPlan.fee) : normalizedPlan.fee; - const totals = { - grand_total: selectedFee, - subtotal: selectedFee, - tax_total: { amount: 0, amount_formatted: '0.00', currency: 'usd', currency_symbol: '$' }, - total_due_after_free_trial: selectedFee, - total_due_now: selectedFee, - }; - const paymentMethod = paymentMethods[0] ?? null; + const preferredPlanId = readStringParam(body, ['plan_id', 'planId', 'plan'], url.searchParams); + const rawPeriod = readStringParam( + body, + [ + 'plan_period', + 'planPeriod', + 'interval', + 'billing_interval', + 'billingInterval', + 'billing_period', + 'billingPeriod', + 'cycle', + 'billing_cycle', + 'billingCycle', + 'period', + ], + url.searchParams, + ); + const planPeriod = normalizePlanPeriod(rawPeriod); + const plan = resolveBillingPlan('user', preferredPlanId); + const paymentMethod = getPrimaryPaymentMethod(); + const checkout = BillingService.createCheckout(plan, { + id: checkoutId, + plan_period: planPeriod, + payer: createBillingPayer(), + payment_method: paymentMethod, + needs_payment_method: !paymentMethod, + external_client_secret: `mock_checkout_secret_${checkoutId}`, + external_gateway_id: 'mock_gateway', + status: 'needs_confirmation', + }); const intervalLabel = planPeriod === 'annual' ? 'year' : 'month'; return createNoStoreResponse({ response: { + ...checkout, billing_interval: intervalLabel, - external_client_secret: `mock_checkout_secret_${checkoutId}`, - external_gateway_id: 'mock_gateway', - free_trial_ends_at: normalizedPlan.free_trial_enabled ? now + 14 * 24 * 60 * 60 * 1000 : null, - id: checkoutId, interval: intervalLabel, - is_immediate_plan_change: true, - needs_payment_method: needsPaymentMethod, - object: 'commerce_checkout', - payer, - payment_method: paymentMethod, - plan: { - ...normalizedPlan, - fee: planPeriod === 'annual' ? (normalizedPlan.annual_fee ?? normalizedPlan.fee) : normalizedPlan.fee, - }, - plan_period: planPeriod, - plan_period_start: now, - status: 'needs_confirmation', - totals, }, }); }), @@ -1634,204 +2126,129 @@ export const clerkHandlers = [ 'https://*.clerk.accounts.dev/v1/me/commerce/checkouts/:checkoutId/confirm', async ({ params, request }) => { if (!currentSession || !currentUser) { - return createNoStoreResponse({ error: 'No active session' }, { status: 401 }); - } - - let body: Record = {}; - let formBody: URLSearchParams | null = null; - - const text = await request.text(); - if (text) { - try { - body = JSON.parse(text); - } catch { - formBody = new URLSearchParams(text); - formBody.forEach((value, key) => { - body[key] = value; - }); - } + return createUnauthorizedResponse(); } - const urlParams = new URL(request.url).searchParams; - const getParam = (key: string) => { - const lower = key.toLowerCase(); - return ( - body[key] ?? - body?.params?.[key] ?? - body[lower] ?? - body?.params?.[lower] ?? - formBody?.get(key) ?? - formBody?.get(lower) ?? - urlParams.get(key) ?? - urlParams.get(lower) - ); - }; - - const plansResponse = BillingService.getPlans(); - const plans = plansResponse.response?.data ?? plansResponse.data ?? []; - const preferredPlanId = getParam('plan_id') || getParam('planId') || getParam('plan'); - const rawPeriod = - getParam('plan_period') || - getParam('planPeriod') || - getParam('interval') || - getParam('billing_interval') || - getParam('billingInterval') || - getParam('billing_period') || - getParam('billingPeriod') || - getParam('cycle') || - getParam('billing_cycle') || - getParam('billingCycle') || - getParam('period'); - const normalizePeriod = (value: any): 'month' | 'annual' => { - const v = typeof value === 'string' ? value.toLowerCase() : ''; - if (v === 'month' || v === 'monthly') { - return 'month'; - } - if (['annual', 'year', 'yearly', 'annually'].includes(v)) { - return 'annual'; - } - // honor explicit values only; fall back to month when absent/unknown - return 'month'; - }; - const planPeriod: 'month' | 'annual' = rawPeriod ? normalizePeriod(rawPeriod) : 'month'; - const plan = plans.find(item => item.id === preferredPlanId) ?? plans[0]; - - const normalizePlan = (input: any) => { - const safeArray = (value: any) => (Array.isArray(value) ? value : []); - const planData = - input ?? - ({ - annual_fee: null, - annual_monthly_fee: null, - avatar_url: '', - description: 'Mock plan', - 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_mock_default', - is_default: true, - is_recurring: true, - name: 'Mock Plan', - object: 'commerce_plan', - publicly_visible: true, - slug: 'mock-plan', - } as any); - - return { - ...planData, - annual_fee: planData.annual_fee ?? null, - annual_monthly_fee: planData.annual_monthly_fee ?? null, - avatar_url: planData.avatar_url ?? '', - description: planData.description ?? null, - features: safeArray((planData as any).features), - free_trial_days: planData.free_trial_days ?? null, - free_trial_enabled: planData.free_trial_enabled ?? false, - has_base_fee: planData.has_base_fee ?? false, - for_payer_type: planData.for_payer_type ?? 'user', - publicly_visible: planData.publicly_visible ?? true, - }; - }; - - const normalizedPlan = normalizePlan(plan); - const paymentSourcesResult = BillingService.getPaymentSources(currentSession, currentUser); - const paymentMethods = paymentSourcesResult.authorized - ? (paymentSourcesResult.data.data ?? paymentSourcesResult.data.response.data ?? []) - : []; - const needsPaymentMethod = paymentMethods.length === 0; - const selectedFee = - planPeriod === 'annual' ? (normalizedPlan.annual_fee ?? normalizedPlan.fee) : normalizedPlan.fee; - const totals = { - grand_total: selectedFee, - subtotal: selectedFee, - tax_total: { amount: 0, amount_formatted: '0.00', currency: 'usd', currency_symbol: '$' }, - total_due_after_free_trial: selectedFee, - total_due_now: selectedFee, - }; - const now = Date.now(); + const url = new URL(request.url); + const body = await parseRequestBodyAsRecord(request); + const preferredPlanId = readStringParam(body, ['plan_id', 'planId', 'plan'], url.searchParams); + const rawPeriod = readStringParam( + body, + [ + 'plan_period', + 'planPeriod', + 'interval', + 'billing_interval', + 'billingInterval', + 'billing_period', + 'billingPeriod', + 'cycle', + 'billing_cycle', + 'billingCycle', + 'period', + ], + url.searchParams, + ); + const planPeriod = normalizePlanPeriod(rawPeriod); + const plan = resolveBillingPlan('user', preferredPlanId); + const paymentMethod = getPrimaryPaymentMethod(); const checkoutId = params.checkoutId as string; + const checkout = BillingService.createCheckout(plan, { + id: checkoutId, + plan_period: planPeriod, + status: 'completed', + payer: createBillingPayer(), + payment_method: paymentMethod, + needs_payment_method: !paymentMethod, + external_client_secret: `mock_checkout_secret_${checkoutId}`, + external_gateway_id: 'mock_gateway', + }); - const payer = { - created_at: now, - email: (currentUser as any).emailAddresses?.[0]?.emailAddress ?? `${currentUser?.id}@example.com`, - first_name: (currentUser as any).firstName ?? null, - id: `payer_${currentUser?.id ?? 'mock'}`, - last_name: (currentUser as any).lastName ?? null, - object: 'commerce_payer', - organization_id: null, - organization_name: null, - updated_at: now, - user_id: currentUser?.id ?? null, - }; + setStoredSubscriptionForPayerType( + 'user', + BillingService.createSubscription(plan, { + subscription_items: [ + BillingService.createSubscriptionItem(plan, { + plan_period: planPeriod, + }), + ], + }), + ); return createNoStoreResponse({ - response: { - external_client_secret: `mock_checkout_secret_${checkoutId}`, - external_gateway_id: 'mock_gateway', - free_trial_ends_at: normalizedPlan.free_trial_enabled ? now + 14 * 24 * 60 * 60 * 1000 : null, - id: checkoutId, - is_immediate_plan_change: true, - needs_payment_method: needsPaymentMethod, - object: 'commerce_checkout', - payer, - payment_method: paymentMethods[0] ?? null, - plan: { - ...normalizedPlan, - fee: planPeriod === 'annual' ? (normalizedPlan.annual_fee ?? normalizedPlan.fee) : normalizedPlan.fee, - }, - plan_period: planPeriod, - plan_period_start: now, - status: 'completed', - totals, - }, + response: checkout, }); }, ), // Commerce endpoints - Plans - http.get('*/v1/commerce/plans', () => { - return createNoStoreResponse(BillingService.getPlans()); + http.get('*/v1/commerce/plans', ({ request }) => { + const url = new URL(request.url); + const payerTypeParam = url.searchParams.get('payer_type'); + const payerType = + payerTypeParam === 'org' || payerTypeParam === 'user' + ? (payerTypeParam as BillingPlanJSON['for_payer_type']) + : undefined; + const { limit, offset } = parsePagination(url.searchParams); + const plans = payerType ? getBillingPlansForPayerType(payerType) : getBillingPlans(); + const paginated = paginateCollection(plans, limit, offset); + + return createNoStoreResponse({ + data: paginated.data, + response: paginated, + total_count: paginated.total_count, + }); }), // Commerce endpoints - User subscription creation (trial/start) http.post('*/v1/me/commerce/subscription', async ({ request }) => { - let body: any = {}; - try { - body = await request.json(); - } catch { - body = {}; + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); } - const result = BillingService.startFreeTrial(currentSession, currentUser); - - if (!result.authorized) { - return createNoStoreResponse({ error: result.error }, { status: result.status }); + try { + await request.json(); + } catch { + // ignore request body, this endpoint only toggles trial state in mocks } + const trialPlan = + getBillingPlansForPayerType('user').find(plan => plan.free_trial_enabled) ?? getDefaultBillingPlan('user'); + const subscription = BillingService.createFreeTrialSubscription(trialPlan); + setStoredSubscriptionForPayerType('user', subscription); - currentSubscription = result.data.response; - - return createNoStoreResponse(result.data); + return createNoStoreResponse({ response: subscription }); }), // Commerce endpoints - User subscription (singular) http.get('*/v1/me/commerce/subscription', () => { - const result = BillingService.getSubscription(currentSession, currentUser, currentSubscription); - - if (!result.authorized) { - return createNoStoreResponse({ error: result.error }, { status: result.status }); + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); } - return createNoStoreResponse(result.data); + + return createNoStoreResponse({ response: getBillingSubscriptionForPayerType('user') }); }), // Commerce endpoints - User subscriptions (plural) http.get('*/v1/me/commerce/subscriptions', () => { - return createNoStoreResponse(BillingService.getSubscriptions()); + return createNoStoreResponse({ + data: [getBillingSubscriptionForPayerType('user')], + total_count: 1, + }); }), // Commerce endpoints - Statements - http.get('*/v1/me/commerce/statements', () => { - return createNoStoreResponse(BillingService.getStatements()); + http.get('*/v1/me/commerce/statements', ({ request }) => { + if (!currentSession || !currentUser) { + return createUnauthorizedResponse(); + } + + const url = new URL(request.url); + const { limit, offset } = parsePagination(url.searchParams); + const subscription = getBillingSubscriptionForPayerType('user'); + const plan = subscription.subscription_items?.[0]?.plan ?? getDefaultBillingPlan('user'); + const statements = paginateCollection([BillingService.createStatement(plan)], limit, offset); + + return createNoStoreResponse(statements); }), // Image endpoints