diff --git a/web/app/[locale]/components/pro-welcome-banner.tsx b/web/app/[locale]/components/pro-welcome-banner.tsx index 3c926a5a8cf2..64e31ef7dfce 100644 --- a/web/app/[locale]/components/pro-welcome-banner.tsx +++ b/web/app/[locale]/components/pro-welcome-banner.tsx @@ -3,8 +3,8 @@ import { useTranslations } from "next-intl"; import { useSearchParams } from "next/navigation"; -// Reads the ?welcome= / ?billing= states set by /api/billing/checkout and -// /api/billing/confirm so the /pro page itself can stay static. +// Reads the ?welcome= / ?billing= states set by /api/billing/checkout so the +// /pro page itself can stay static. // Render inside (useSearchParams requirement). export function ProWelcomeBanner() { const t = useTranslations("pricing"); @@ -25,13 +25,11 @@ export function ProWelcomeBanner() { ? t("billingError") : billing === "unavailable" ? t("billingUnavailable") - : billing === "external" - ? t("billingExternal") - : billing === "cancelled" - ? t("billingCancelled") - : billing === "invalid_plan" - ? t("billingInvalidPlan") - : null; + : billing === "cancelled" + ? t("billingCancelled") + : billing === "invalid_plan" + ? t("billingInvalidPlan") + : null; if (!message) return null; return ( @@ -44,7 +42,7 @@ export function ProWelcomeBanner() { <> {" "} {t("welcomePendingAction")} diff --git a/web/app/[locale]/dashboard/billing/page.tsx b/web/app/[locale]/dashboard/billing/page.tsx index b488ace7e6f3..3e070c398902 100644 --- a/web/app/[locale]/dashboard/billing/page.tsx +++ b/web/app/[locale]/dashboard/billing/page.tsx @@ -107,7 +107,7 @@ export default async function DashboardBillingPage({ canManageBilling={hasStripeCustomer} /> ) : ( - + )} {billingTeam && teamSubscription ? ( @@ -442,15 +442,6 @@ function TeamPlan({ ); } -function LegacyPlan({ t }: { t: Awaited> }) { - return ( -
-

{t("pro.name")}

-

{t("legacy.body")}

-
- ); -} - function BillingMetric({ label, value }: { label: string; value: string }) { return (
diff --git a/web/app/[locale]/pricing/page.tsx b/web/app/[locale]/pricing/page.tsx index 4784dd6aab52..8ba323891551 100644 --- a/web/app/[locale]/pricing/page.tsx +++ b/web/app/[locale]/pricing/page.tsx @@ -80,7 +80,7 @@ export default async function PricingPage({
- {/* Post-checkout / billing states from /api/billing/checkout|confirm */} + {/* Post-checkout / billing states from /api/billing/checkout */} @@ -117,15 +117,9 @@ export default async function PricingPage({ {snapshot.isPro ? (
{t("currentPlan")} - {snapshot.billingManagement === "stripe" ? ( - - {t("manageBilling")} - - ) : ( -

- {t("billingExternal")} -

- )} + + {t("manageBilling")} +
) : ( @@ -277,7 +271,7 @@ export default async function PricingPage({ async function currentPlanSnapshot(): Promise<{ isPro: boolean; - billingManagement: "stripe" | "external" | "none"; + billingManagement: "stripe" | "none"; }> { if (!isStackConfigured()) { return { isPro: false, billingManagement: "none" }; diff --git a/web/app/api/billing/checkout/route.ts b/web/app/api/billing/checkout/route.ts index 53c8368810a3..cc6fdb4e7dbb 100644 --- a/web/app/api/billing/checkout/route.ts +++ b/web/app/api/billing/checkout/route.ts @@ -7,9 +7,6 @@ import { isAppStoreDistributionMode } from "../../../lib/billing"; import { cloudDb } from "../../../../db/client"; import { stripeCustomers } from "../../../../db/schema"; import { - PRO_PRODUCT_ID, - TEAM_PRODUCT_ID, - hasActiveProSubscription, resolveProPlanStatus, syncProPlanMetadata, } from "../../../../services/billing/pro"; @@ -27,9 +24,7 @@ export const dynamic = "force-dynamic"; type CheckoutStackServerApp = StackServerApp; // One-click upgrade entrypoint. Signed-out visitors become anonymous Stack -// users first, then go straight to the hosted purchase page. Stack keeps the -// product grant attached to that anonymous user until the buyer completes -// account setup with an email. +// users first, then go straight to Stripe Checkout. export async function GET(request: NextRequest) { if ( isAppStoreDistributionMode({ @@ -50,14 +45,19 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL("/pricing?billing=invalid_plan", request.url)); } - if (plan === "pro" && isStripeBillingConfigured()) { + if (!isStripeBillingConfigured()) { + return NextResponse.redirect(new URL("/pricing?billing=unavailable", request.url)); + } + + if (plan === "pro") { return stripeProCheckout(request, stackServerApp); } - if (plan === "team" && isStripeBillingConfigured()) { + if (plan === "team") { return stripeTeamCheckout(request, stackServerApp); } - - return legacyStackCheckout(request, stackServerApp, plan); + // checkoutPlan only yields "pro" | "team" | null (null handled above); this is + // unreachable but keeps GET returning a NextResponse instead of possibly-undefined. + return NextResponse.redirect(new URL("/pricing?billing=invalid_plan", request.url)); } async function stripeProCheckout( @@ -185,63 +185,6 @@ async function stripeTeamCheckout( } } -async function legacyStackCheckout( - request: NextRequest, - stackServerApp: CheckoutStackServerApp, - plan: "pro" | "team", -) { - const user = - (await stackServerApp.getUser({ or: "return-null" })) ?? - (await stackServerApp.getUser({ or: "anonymous" })); - if (isAccountDeletionInProgress(user)) { - return accountDeletionCheckoutRedirect(request); - } - - if (plan === "pro" && (await hasActiveProSubscription(user))) { - await syncProPlanMetadata(user, true); - return NextResponse.redirect(new URL("/pricing?welcome=active", request.url)); - } - - const returnUrl = new URL( - plan === "pro" ? "/api/billing/confirm" : "/pricing?welcome=team", - request.url, - ).toString(); - let checkoutUrl: string; - const productId = plan === "pro" ? PRO_PRODUCT_ID : TEAM_PRODUCT_ID; - const customer = plan === "pro" ? user : await checkoutTeamCustomer(user); - try { - checkoutUrl = await customer.createCheckoutUrl({ - productId, - returnUrl, - }); - } catch (error) { - // "Already granted" error text is only a hint — re-read the authoritative - // subscription state before treating the buyer as Pro, so a lookalike - // error message can never mint an entitlement. - if (plan === "pro" && isAlreadyGrantedError(error)) { - if (await hasActiveProSubscription(user)) { - await syncProPlanMetadata(user, true); - return NextResponse.redirect(new URL("/pricing?welcome=active", request.url)); - } - // Stack refused the checkout as already-granted but the products read - // does not show Pro yet (replication lag). The confirm route's bounded - // poll settles it and syncs metadata from the verified state. - return NextResponse.redirect(new URL("/api/billing/confirm", request.url)); - } - // return_url must be on a domain the Stack project trusts; previews and - // local dev ports may not be. The purchase still works without it — the - // buyer stays on the hosted receipt, and Pro state is picked up by the - // read-time reconcile on VM create or the next visit to this route. - try { - checkoutUrl = await customer.createCheckoutUrl({ productId }); - } catch (retryError) { - console.error("[Billing] createCheckoutUrl failed", error, retryError); - return NextResponse.redirect(new URL("/pricing?billing=error", request.url)); - } - } - return NextResponse.redirect(checkoutUrl); -} - function accountDeletionCheckoutRedirect(request: NextRequest) { return NextResponse.redirect( new URL("/pricing?billing=account_deletion_in_progress", request.url), @@ -262,10 +205,6 @@ type CheckoutTeamCustomer = { readonly id?: string; readonly displayName?: string | null; listUsers?(): Promise; - createCheckoutUrl(options: { - productId: string; - returnUrl?: string; - }): Promise; }; type CheckoutTeamUser = { @@ -369,12 +308,6 @@ function appStorePricingRedirect(request: NextRequest): URL { return redirectURL; } -function isAlreadyGrantedError(error: unknown): boolean { - const text = - error instanceof Error ? `${error.name} ${error.message}` : String(error); - return /already.{0,20}granted/i.test(text); -} - function isStackTeamUniqueConflict(error: unknown): boolean { const cause = (error as { cause?: unknown } | null)?.cause; const candidate = (cause ?? error) as { code?: string; constraint?: string } | null; diff --git a/web/app/api/billing/confirm/route.ts b/web/app/api/billing/confirm/route.ts deleted file mode 100644 index cdf503c3050f..000000000000 --- a/web/app/api/billing/confirm/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { stackServerApp } from "../../../lib/stack"; -import { - hasActiveProSubscription, - resolveProPlanStatus, - syncProPlanMetadata, -} from "../../../../services/billing/pro"; - -export const dynamic = "force-dynamic"; - -const VERIFY_ATTEMPTS = 4; -const VERIFY_SPACING_MS = 1500; -const ANONYMOUS_IF_EXISTS = "anonymous-if-exists[deprecated]" as const; - -// Stack's hosted purchase page returns here after payment. Stripe confirms -// asynchronously, so the subscription can lag the redirect by a moment — -// poll briefly (bounded, external system) before deciding. -export async function GET(request: NextRequest) { - if (!stackServerApp) { - return NextResponse.redirect(new URL("/pricing", request.url)); - } - const user = await stackServerApp.getUser({ or: ANONYMOUS_IF_EXISTS }); - if (!user) { - return NextResponse.redirect(new URL("/pricing", request.url)); - } - - const app = stackServerApp; - let isPro = false; - for (let attempt = 0; attempt < VERIFY_ATTEMPTS; attempt++) { - if (attempt > 0) { - await new Promise((resolve) => setTimeout(resolve, VERIFY_SPACING_MS)); - // Client gone (edge timeout, closed tab): stop polling. The pending - // banner's "check again" link re-runs this route, so nothing is lost. - if (request.signal.aborted) break; - } - // App-level lookup each attempt so no per-object store caching can - // return a stale product list mid-poll. - isPro = await hasActiveProSubscription({ - listProducts: (options) => - app.listProducts({ userId: user.id, ...options }), - }); - if (isPro) break; - } - - if (isPro) { - await syncProPlanMetadata(user, true); - } else { - // The Stack poll saw no Pro product. Resolve the full plan status - // (Stack products + Stripe subscriptions) before touching metadata so a - // Stripe-billed Pro user who lands on this legacy return URL is never - // downgraded. resolveProPlanStatus syncs metadata in both directions and - // respects the manual cmuxVmPlan override, so a genuinely lapsed - // subscription still gets cleared here. - isPro = (await resolveProPlanStatus(user)).isPro; - } - return NextResponse.redirect( - new URL( - isPro ? "/pricing?welcome=success" : "/pricing?welcome=pending", - request.url, - ), - ); -} diff --git a/web/app/api/billing/plan/route.ts b/web/app/api/billing/plan/route.ts index bb2077713543..cc9375990190 100644 --- a/web/app/api/billing/plan/route.ts +++ b/web/app/api/billing/plan/route.ts @@ -1,11 +1,11 @@ import { NextRequest } from "next/server"; import { getStackServerApp, isStackConfigured } from "../../../lib/stack"; +import { isStripeBillingConfigured } from "../../../../services/billing/stripe"; import { parseBearer, jsonResponse } from "../../../../services/vms/routeHelpers"; import { FREE_PLAN_ID, TEAM_PLAN_ID, hasActiveTeamSubscriptionForTeam, - metadataPlanId, resolveProPlanStatus, type BillingManagementKind, } from "../../../../services/billing/pro"; @@ -32,6 +32,7 @@ export async function GET(request: NextRequest) { }); } + const billingAvailable = isStripeBillingConfigured(); const stackServerApp = getStackServerApp(); const bearer = parseBearer(request); const user = bearer @@ -49,7 +50,7 @@ export async function GET(request: NextRequest) { if (!user) { return jsonResponse({ authenticated: false, - billingAvailable: true, + billingAvailable, planId: FREE_PLAN_ID, isPro: false, billingManagement: "none", @@ -63,7 +64,7 @@ export async function GET(request: NextRequest) { const teamStatus = await resolveTeamPlanStatus(user); return jsonResponse({ authenticated: !user.isAnonymous, - billingAvailable: true, + billingAvailable, planId: status.planId, isPro: status.isPro, billingManagement: status.billingManagement, @@ -90,12 +91,8 @@ async function resolveTeamPlanStatus(user: BillingTeamUserLike): Promise { if (!refreshedUser) return unauthorized(); user = refreshedUser; } - // Read-time reconcile: a Pro purchase that never hit - // /api/billing/confirm, or a lapsed subscription, is corrected here + // Read-time reconcile: a Stripe subscription change is corrected here // right before paid limits apply. Best-effort — billing reads must // not block VM creation, so the whole reconcile races a hard // deadline and VM create proceeds with current metadata on timeout. @@ -369,7 +368,7 @@ export async function POST(request: Request): Promise { } // Upper bound on how long VM creation waits for the best-effort billing -// reconcile (Stack product pages + Stripe subscription lookup). On timeout +// reconcile (Stripe subscription lookup). On timeout // the reconcile keeps running in the background (its result is logged, not // awaited) and VM create proceeds with the user's current plan metadata. const BILLING_RECONCILE_DEADLINE_MS = 5_000; diff --git a/web/app/app-pricing/page.tsx b/web/app/app-pricing/page.tsx index e621151ba580..37c9c08f0b27 100644 --- a/web/app/app-pricing/page.tsx +++ b/web/app/app-pricing/page.tsx @@ -115,14 +115,10 @@ export default async function AppPricingPage({ {snapshot.isPro ? (
{pricing.currentPlan} - {snapshot.billingManagement === "stripe" && !appStorePaymentGated ? ( + {appStorePaymentGated ? null : ( {pricing.manageBilling} - ) : ( -

- {pricing.billingExternal} -

)}
) : appStorePaymentGated ? ( @@ -254,7 +250,7 @@ type AppPlanSnapshot = { authenticated: boolean; planId: string; isPro: boolean; - billingManagement: "stripe" | "external" | "none"; + billingManagement: "stripe" | "none"; email: string | null; }; @@ -307,12 +303,6 @@ function appPricingBanner( if (welcome === "active") { return { message: pricing.welcomeActive }; } - if (welcome === "pending") { - return { - message: pricing.welcomePending, - action: { href: "/api/billing/confirm", label: pricing.welcomePendingAction }, - }; - } if (welcome === "team") { return { message: pricing.welcomeTeam }; } @@ -322,9 +312,6 @@ function appPricingBanner( if (billing === "unavailable") { return { message: pricing.billingUnavailable }; } - if (billing === "external") { - return { message: pricing.billingExternal }; - } if (billing === "cancelled") { return { message: pricing.billingCancelled }; } diff --git a/web/app/env.ts b/web/app/env.ts index d1b0163484de..2ec34008acca 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -62,8 +62,8 @@ export const env = createEnv({ // change without a code edit. STRIPE_FOUNDERS_WEBHOOK_SECRET: z.string().min(1).optional(), CMUX_FOUNDERS_FROM_EMAIL: z.string().email().optional(), - // Direct Stripe billing for cmux Pro. Optional: when unset, checkout keeps - // using the legacy Stack-hosted product flow. + // Direct Stripe billing for cmux Pro. Optional: when unset, checkout is + // unavailable. STRIPE_SECRET_KEY: z.string().min(1).optional(), STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), STRIPE_PRO_MONTHLY_PRICE_ID: z.string().min(1).optional(), diff --git a/web/app/lib/feature-flags.ts b/web/app/lib/feature-flags.ts index 52e16cea52da..46009f8d979f 100644 --- a/web/app/lib/feature-flags.ts +++ b/web/app/lib/feature-flags.ts @@ -36,7 +36,7 @@ export const FEATURE_FLAGS = { key: "pro-checkout-enabled-release", owner: "lawrencecchen", description: - "Points the pricing page Pro CTA at /api/billing/checkout (Stack hosted purchase) instead of the download link. Off until prod Stripe Connect is live.", + "Points the pricing page Pro CTA at /api/billing/checkout instead of the download link. Off until prod Stripe is live.", reviewBy: "2026-10-01", defaultWhenUnavailable: false, }, diff --git a/web/messages/en.json b/web/messages/en.json index fe3a4a7a5cbd..a26f3db6d674 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -101,9 +101,6 @@ "pendingBody": "{team} is scheduled to end on {date}.", "price": "$35/seat/month" }, - "legacy": { - "body": "Your subscription is managed by our previous billing system. Contact support to make changes." - }, "details": { "renewsOn": "Renews on", "endsOn": "Ends on", @@ -617,7 +614,6 @@ "welcomePendingAction": "Check again", "billingError": "Something went wrong starting checkout. Please try again in a moment.", "billingUnavailable": "Billing is not available right now. Please try again later.", - "billingExternal": "Your subscription is managed by our previous billing system. Contact support to make changes.", "billingCancelled": "Checkout cancelled. You have not been charged.", "billingInvalidPlan": "That plan is not available. Pick a plan below.", "welcomeTeam": "Your cmux Team purchase is complete." diff --git a/web/messages/ja.json b/web/messages/ja.json index 4251305dfcb1..4f6370e230e6 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -101,9 +101,6 @@ "pendingBody": "{team}は{date}に終了する予定です。", "price": "$35/シート/月" }, - "legacy": { - "body": "サブスクリプションは以前の請求システムで管理されています。変更するにはサポートにお問い合わせください。" - }, "details": { "renewsOn": "更新日", "endsOn": "終了日", @@ -617,7 +614,6 @@ "welcomePendingAction": "再確認", "billingError": "チェックアウトの開始中に問題が発生しました。しばらくしてからもう一度お試しください。", "billingUnavailable": "現在、決済をご利用いただけません。時間をおいてお試しください。", - "billingExternal": "サブスクリプションは以前の請求システムで管理されています。変更するにはサポートにお問い合わせください。", "billingCancelled": "チェックアウトはキャンセルされました。請求は発生していません。", "billingInvalidPlan": "指定されたプランはご利用いただけません。以下からプランをお選びください。", "welcomeTeam": "cmux Team のご購入が完了しました。" diff --git a/web/scripts/stripe/dev-reset.sh b/web/scripts/stripe/dev-reset.sh index 879e82d5edca..43a3473e68b5 100755 --- a/web/scripts/stripe/dev-reset.sh +++ b/web/scripts/stripe/dev-reset.sh @@ -368,8 +368,7 @@ list_and_clean_stack_products() { return fi - # Stack SDK listProducts maps to: - # GET /api/v1/payments/products/user/?limit=50&cursor= + # Scan the Stack payments products endpoint for stale dev grants. if [[ -n "$cursor" ]]; then products_body="$(stack_request GET "/payments/products/user/${encoded_user}?limit=50&cursor=$(urlencode "$cursor")")" else @@ -487,10 +486,10 @@ if [[ -n "$DB_PORT" ]]; then SUMMARY+=("Local DB cleanup on port ${DB_PORT}: $(printf '%s' "$db_output" | tr '\n' ';' | sed -E 's/[[:space:]]+/ /g; s/;+$//')") fi -# Re-check after all cleanup: a Stack-era paid product survives cancellation -# until its period ends (the API has no early-revoke), and a comped grant has -# quantity > 0 with no subscription. Either keeps hasActiveProSubscription -# true, so the checkout gate will still say "already active" for this account. +# Re-check after all cleanup: a Stack-era paid product can survive cancellation +# until its period ends (the API has no early-revoke), and a comped grant can +# have quantity > 0 with no subscription. These no longer grant cmux Pro, but +# they are still useful to report during dev cleanup. residual_body="$(stack_request GET "/payments/products/user/$(urlencode "$stack_user_id")?limit=50" || true)" residual="$(printf '%s' "$residual_body" | node -e ' let raw = ""; diff --git a/web/services/billing/pro.ts b/web/services/billing/pro.ts index 00be0c52ba7f..0f3adbbb2e7a 100644 --- a/web/services/billing/pro.ts +++ b/web/services/billing/pro.ts @@ -1,9 +1,5 @@ // cmux Pro subscription helpers. // -// The `pro` product (user-scoped, product line `cmux-pro`) lives in the Stack -// Auth project config, not in this repo. Prices: yearly $240 (listed first so -// the hosted purchase page pre-selects it) and monthly $30. -// // VM entitlements (services/vms/auth.ts) read the plan id from the user's // `clientReadOnlyMetadata.cmuxPlan`, so syncing that key after a verified // purchase is what upgrades Cloud VM limits — no VM code changes needed. @@ -16,37 +12,12 @@ import { cloudDb } from "../../db/client"; import { stripeSubscriptions } from "../../db/schema"; import { resolveBillingTeam, type BillingTeamUserLike } from "./teamResolution"; -export const PRO_PRODUCT_ID = "pro"; -export const TEAM_PRODUCT_ID = process.env.CMUX_TEAM_PRODUCT_ID?.trim() || "team"; export const PRO_PLAN_ID = "pro"; export const TEAM_PLAN_ID = "team"; export const FREE_PLAN_ID = "free"; export const PRO_ACCESS_ITEM_ID = "cmux-pro-access"; export const ACTIVE_STRIPE_PRO_STATUSES = ["active", "trialing", "past_due"] as const; -const PRODUCTS_PAGE_LIMIT = 50; -const MAX_PRODUCT_PAGES = 10; - -type CustomerProductLike = { - readonly id: string | null; - readonly quantity: number; - readonly subscription: null | { - readonly cancelAtPeriodEnd: boolean; - readonly currentPeriodEnd: Date | null; - }; -}; - -type ProductsPage = readonly CustomerProductLike[] & { - readonly nextCursor: string | null; -}; - -export type ProductsCustomer = { - listProducts(options?: { - cursor?: string; - limit?: number; - }): Promise; -}; - // Mirrors Stack's ReadonlyJson so ServerUser.update stays assignable. export type ProMetadataJson = | null @@ -63,36 +34,6 @@ export type ProMetadataCustomer = { }): Promise; }; -/** - * True when the customer owns the `pro` product, either through an active - * subscription (including one set to cancel at period end — access lasts - * until the period actually ends) or a manual `grantProduct` comp - * (subscription null, quantity > 0). - */ -export async function hasActiveProSubscription( - customer: ProductsCustomer, -): Promise { - let cursor: string | undefined; - for (let page = 0; page < MAX_PRODUCT_PAGES; page++) { - const products = await customer.listProducts({ - cursor, - limit: PRODUCTS_PAGE_LIMIT, - }); - for (const product of products) { - if (product.id !== PRO_PRODUCT_ID) continue; - if (product.subscription !== null) { - const end = product.subscription.currentPeriodEnd; - if (!end || end.getTime() > Date.now()) return true; - continue; - } - if (product.quantity > 0) return true; - } - if (!products.nextCursor) return false; - cursor = products.nextCursor; - } - return false; -} - /** * Writes `cmuxPlan: "pro"` into the user's clientReadOnlyMetadata when Pro is * active, and removes it when Pro lapsed. No-op when already in sync. @@ -120,12 +61,12 @@ export async function syncProPlanMetadata( await user.update({ clientReadOnlyMetadata: metadata as ProMetadataJson }); } -export type ProReconcileUser = ProductsCustomer & ProMetadataCustomer & { +export type ProReconcileUser = ProMetadataCustomer & { readonly id?: string; }; export type ActiveStripeSubscriptionQuery = (stackUserId: string) => Promise; -export type BillingManagementKind = "stripe" | "external" | "none"; +export type BillingManagementKind = "stripe" | "none"; export type ProPlanStatus = { readonly planId: typeof FREE_PLAN_ID | typeof PRO_PLAN_ID; @@ -138,11 +79,9 @@ export type ProPlanStatus = { /** * Read-time reconciliation: compares the `cmuxPlan` metadata against the - * actual Pro subscription state and syncs it in either direction (upgrade - * that never hit /api/billing/confirm, or a lapse the user never revisited - * billing to observe). Skipped when a manual `cmuxVmPlan` override is set — - * that key wins in plan resolution and is operator-owned. Returns true when - * metadata was changed. + * actual Stripe Pro subscription state and syncs it in either direction. + * Skipped when a manual `cmuxVmPlan` override is set — that key wins in plan + * resolution and is operator-owned. Returns true when metadata was changed. */ export async function reconcileProPlanMetadata( user: ProReconcileUser, @@ -156,7 +95,9 @@ export async function reconcileProPlanMetadata( const override = metadata.cmuxVmPlan; if (typeof override === "string" && override.trim()) return false; - const isPro = await hasAnyActiveProSubscription(user, options.hasActiveStripeSubscription); + const isPro = user.id + ? await (options.hasActiveStripeSubscription ?? hasActiveStripeProSubscription)(user.id) + : false; if (isPro === (metadata.cmuxPlan === PRO_PLAN_ID)) return false; await syncProPlanMetadata(user, isPro); return true; @@ -169,11 +110,9 @@ export async function resolveProPlanStatus( const metadata = proMetadataRecord(user.clientReadOnlyMetadata); const hasManualVmPlanOverride = hasManualVmOverride(metadata); const metadataPlanId = planIdFromMetadata(metadata); - const subscriptionState = await activeProSubscriptionState( - user, - options.hasActiveStripeSubscription, - ); - const isPro = subscriptionState.stackActive || subscriptionState.stripeActive; + const isPro = user.id + ? await (options.hasActiveStripeSubscription ?? hasActiveStripeProSubscription)(user.id) + : false; let metadataChanged = false; if (!hasManualVmPlanOverride && isPro !== (metadataPlanId === PRO_PLAN_ID)) { @@ -184,11 +123,7 @@ export async function resolveProPlanStatus( return { planId: isPro ? PRO_PLAN_ID : FREE_PLAN_ID, isPro, - billingManagement: subscriptionState.stripeActive - ? "stripe" - : isPro - ? "external" - : "none", + billingManagement: isPro ? "stripe" : "none", metadataPlanId, hasManualVmPlanOverride, metadataChanged, @@ -280,26 +215,6 @@ export async function syncTeamPlanMetadata( await team.update({ clientReadOnlyMetadata: metadata as ProMetadataJson }); } -async function hasAnyActiveProSubscription( - user: ProReconcileUser, - hasActiveStripeSubscription: ActiveStripeSubscriptionQuery = hasActiveStripeProSubscription, -): Promise { - const state = await activeProSubscriptionState(user, hasActiveStripeSubscription); - return state.stackActive || state.stripeActive; -} - -async function activeProSubscriptionState( - user: ProReconcileUser, - hasActiveStripeSubscription: ActiveStripeSubscriptionQuery = hasActiveStripeProSubscription, -): Promise<{ stackActive: boolean; stripeActive: boolean }> { - const stackActive = - typeof user.listProducts === "function" - ? await hasActiveProSubscription(user) - : false; - const stripeActive = user.id ? await hasActiveStripeSubscription(user.id) : false; - return { stackActive, stripeActive }; -} - function proMetadataRecord(raw: unknown): Record { return raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) diff --git a/web/tests/app-pricing-page.test.tsx b/web/tests/app-pricing-page.test.tsx index 55854b9e34d1..b678a3e87659 100644 --- a/web/tests/app-pricing-page.test.tsx +++ b/web/tests/app-pricing-page.test.tsx @@ -43,21 +43,6 @@ const proUser = { isAnonymous: false, primaryEmail: "pro@example.com", clientReadOnlyMetadata: { cmuxPlan: "pro" }, - listProducts: mock(async () => - Object.assign( - [ - { - id: "pro", - quantity: 1, - subscription: { - cancelAtPeriodEnd: false, - currentPeriodEnd: null, - }, - }, - ], - { nextCursor: null }, - ), - ), update: mock(async () => undefined), }; @@ -90,7 +75,6 @@ describe("app pricing page", () => { stackConfigured = false; currentUser = null; stripeSubscriptionRows = []; - proUser.listProducts.mockClear(); proUser.update.mockClear(); }); @@ -134,7 +118,7 @@ describe("app pricing page", () => { expect(html).toContain("Billing is not available right now. Please try again later."); }); - test("renders the external billing note without a portal link for Stack Pro users", async () => { + test("renders Stack metadata-only Pro users as Free", async () => { stackConfigured = true; currentUser = proUser; @@ -148,12 +132,11 @@ describe("app pricing page", () => { expect(html).not.toContain('href="/api/billing/portal"'); expect(html).toContain( - "Your subscription is managed by our previous billing system. Contact support to make changes.", + "http://localhost:9210/api/billing/checkout?plan=pro&cmux_external_browser=1&cmux_scheme=cmux-dev-test", ); - expect(html).toContain("Current plan"); }); - test("hides Stripe billing management in App Store distribution mode", async () => { + test("hides the billing portal link for Pro users in App Store distribution mode", async () => { stackConfigured = true; currentUser = proUser; stripeSubscriptionRows = [{ id: "sub_123" }]; @@ -167,11 +150,8 @@ describe("app pricing page", () => { }); const html = renderToStaticMarkup(element); - expect(html).not.toContain('href="/api/billing/portal"'); - expect(html).not.toContain("Manage billing"); - expect(html).toContain( - "Your subscription is managed by our previous billing system. Contact support to make changes.", - ); + // Apple 3.1.1: no external billing/purchase links inside App Store builds. + expect(html).not.toContain("/api/billing/portal"); expect(html).toContain("Current plan"); }); diff --git a/web/tests/billing-checkout-route.test.ts b/web/tests/billing-checkout-route.test.ts index eed03f67a315..8dba74b9f9e9 100644 --- a/web/tests/billing-checkout-route.test.ts +++ b/web/tests/billing-checkout-route.test.ts @@ -15,14 +15,11 @@ const teamCustomer = { id: "team-signed-in", displayName: "Signed Team", listUsers: mock(async () => [{ id: "member-1" }, { id: "member-2" }]), - createCheckoutUrl: mock(async () => "https://checkout.test/team"), }; const signedInUser = { id: "user-signed-in", isAnonymous: false, primaryEmail: "signed@example.com", - createCheckoutUrl: mock(async () => "https://checkout.test/signed-in"), - listProducts: mock(async () => emptyProductsPage()), update: mock(async () => undefined), selectedTeam: null as null | typeof teamCustomer, }; @@ -30,8 +27,6 @@ const anonymousUser = { id: "user-anonymous", isAnonymous: true, primaryEmail: null, - createCheckoutUrl: mock(async () => "https://checkout.test/anonymous"), - listProducts: mock(async () => emptyProductsPage()), update: mock(async () => undefined), }; @@ -121,21 +116,11 @@ afterAll(() => { describe("billing checkout route", () => { beforeEach(() => { getUser.mockClear(); - signedInUser.createCheckoutUrl.mockClear(); - signedInUser.listProducts.mockClear(); signedInUser.update.mockClear(); - teamCustomer.createCheckoutUrl.mockClear(); teamCustomer.listUsers.mockClear(); - anonymousUser.createCheckoutUrl.mockClear(); - anonymousUser.listProducts.mockClear(); anonymousUser.update.mockClear(); - signedInUser.createCheckoutUrl.mockResolvedValue("https://checkout.test/signed-in"); - signedInUser.listProducts.mockResolvedValue(emptyProductsPage()); signedInUser.update.mockResolvedValue(undefined); - teamCustomer.createCheckoutUrl.mockResolvedValue("https://checkout.test/team"); teamCustomer.listUsers.mockResolvedValue([{ id: "member-1" }, { id: "member-2" }]); - anonymousUser.createCheckoutUrl.mockResolvedValue("https://checkout.test/anonymous"); - anonymousUser.listProducts.mockResolvedValue(emptyProductsPage()); anonymousUser.update.mockResolvedValue(undefined); signedInUser.selectedTeam = null; userResponses = []; @@ -152,137 +137,52 @@ describe("billing checkout route", () => { stripeLimit.mockResolvedValue([]); }); - test("sends signed-out visitors straight to anonymous Stack checkout", async () => { + test("redirects to billing unavailable when Stripe is not configured", async () => { userResponses = [null, anonymousUser]; const response = await GET( new NextRequest("https://cmux.test/api/billing/checkout"), ); - expect(response.status).toBe(307); - expect(response.headers.get("location")).toBe("https://checkout.test/anonymous"); - expect(getUser).toHaveBeenNthCalledWith(1, { or: "return-null" }); - expect(getUser).toHaveBeenNthCalledWith(2, { or: "anonymous" }); - expect(anonymousUser.createCheckoutUrl).toHaveBeenCalledWith({ - productId: "pro", - returnUrl: "https://cmux.test/api/billing/confirm", - }); - }); - - test("blocks direct checkout requests from the iOS App Store distribution", async () => { - stripeConfigured = true; - userResponses = [null, anonymousUser]; - - const response = await GET( - new NextRequest( - "https://cmux.test/api/billing/checkout?plan=pro&cmux_distribution=appstore&cmux_scheme=cmux", - ), - ); - expect(response.status).toBe(307); expect(response.headers.get("location")).toBe( - "https://cmux.test/app-pricing?cmux_app=1&cmux_distribution=appstore&billing=unavailable&cmux_scheme=cmux", + "https://cmux.test/pricing?billing=unavailable", ); expect(getUser).not.toHaveBeenCalled(); expect(createStripeSession).not.toHaveBeenCalled(); - expect(anonymousUser.createCheckoutUrl).not.toHaveBeenCalled(); - }); - - test("keeps signed-in checkout on the existing Stack user", async () => { - userResponses = [signedInUser]; - - const response = await GET( - new NextRequest("https://cmux.test/api/billing/checkout"), - ); - - expect(response.status).toBe(307); - expect(response.headers.get("location")).toBe("https://checkout.test/signed-in"); - expect(getUser).toHaveBeenCalledTimes(1); - expect(signedInUser.createCheckoutUrl).toHaveBeenCalledWith({ - productId: "pro", - returnUrl: "https://cmux.test/api/billing/confirm", - }); }); - test("blocks Stack checkout while account deletion is in progress", async () => { - userResponses = [{ - ...signedInUser, - clientReadOnlyMetadata: { cmuxAccountDeleting: true }, - }]; - - const response = await GET( - new NextRequest("https://cmux.test/api/billing/checkout"), - ); - - expect(response.status).toBe(307); - expect(response.headers.get("location")).toBe( - "https://cmux.test/pricing?billing=account_deletion_in_progress", - ); - expect(signedInUser.createCheckoutUrl).not.toHaveBeenCalled(); - }); - - test("syncs metadata when Stack says Pro checkout is already granted and products confirm it", async () => { + test("redirects team checkout to billing unavailable when Stripe is not configured", async () => { userResponses = [signedInUser]; - mockImplementation(signedInUser.createCheckoutUrl, async () => { - throw new Error("Product already granted to customer"); - }); - // First read (the route's top pre-check) sees no Pro so the route reaches - // createCheckoutUrl; the catch-path re-verify then sees an active Pro. - let listProductsCalls = 0; - mockImplementation(signedInUser.listProducts, async () => - listProductsCalls++ === 0 ? emptyProductsPage() : activeProProductsPage(), - ); const response = await GET( - new NextRequest("https://cmux.test/api/billing/checkout"), + new NextRequest("https://cmux.test/api/billing/checkout?plan=team"), ); expect(response.status).toBe(307); expect(response.headers.get("location")).toBe( - "https://cmux.test/pricing?welcome=active", + "https://cmux.test/pricing?billing=unavailable", ); - expect(signedInUser.createCheckoutUrl).toHaveBeenCalledTimes(1); - expect(signedInUser.listProducts).toHaveBeenCalled(); - expect(signedInUser.update).toHaveBeenCalledWith({ - clientReadOnlyMetadata: { cmuxPlan: "pro" }, - }); + expect(getUser).not.toHaveBeenCalled(); + expect(createStripeSession).not.toHaveBeenCalled(); }); - test("does not mint Pro metadata when Stack says already granted but products do not confirm it", async () => { - userResponses = [signedInUser]; - mockImplementation(signedInUser.createCheckoutUrl, async () => { - throw new Error("Product already granted to customer"); - }); - signedInUser.listProducts.mockResolvedValue(emptyProductsPage()); + test("blocks direct checkout requests from the iOS App Store distribution", async () => { + stripeConfigured = true; + userResponses = [null, anonymousUser]; const response = await GET( - new NextRequest("https://cmux.test/api/billing/checkout"), + new NextRequest( + "https://cmux.test/api/billing/checkout?plan=pro&cmux_distribution=appstore&cmux_scheme=cmux", + ), ); expect(response.status).toBe(307); expect(response.headers.get("location")).toBe( - "https://cmux.test/api/billing/confirm", - ); - expect(signedInUser.listProducts).toHaveBeenCalled(); - expect(signedInUser.update).not.toHaveBeenCalled(); - }); - - test("routes team checkout through the team Stack product", async () => { - signedInUser.selectedTeam = teamCustomer; - userResponses = [signedInUser]; - - const response = await GET( - new NextRequest("https://cmux.test/api/billing/checkout?plan=team"), + "https://cmux.test/app-pricing?cmux_app=1&cmux_distribution=appstore&billing=unavailable&cmux_scheme=cmux", ); - - expect(response.status).toBe(307); - expect(response.headers.get("location")).toBe("https://checkout.test/team"); - expect(signedInUser.listProducts).not.toHaveBeenCalled(); - expect(signedInUser.createCheckoutUrl).not.toHaveBeenCalled(); - expect(teamCustomer.createCheckoutUrl).toHaveBeenCalledWith({ - productId: "team", - returnUrl: "https://cmux.test/pricing?welcome=team", - }); + expect(getUser).not.toHaveBeenCalled(); + expect(createStripeSession).not.toHaveBeenCalled(); }); test("creates Stripe checkout for anonymous Pro visitors when configured", async () => { @@ -355,7 +255,6 @@ describe("billing checkout route", () => { ); expect(response.headers.get("location")).toBe("https://checkout.stripe.com/c/session"); - expect(teamCustomer.createCheckoutUrl).not.toHaveBeenCalled(); expect(resolveTeamPrice).toHaveBeenCalled(); expect(createStripeCustomer).toHaveBeenCalledWith({ name: "Signed Team", @@ -389,6 +288,24 @@ describe("billing checkout route", () => { }); }); + test("blocks Stripe Pro checkout while account deletion is in progress", async () => { + stripeConfigured = true; + userResponses = [{ + ...signedInUser, + clientReadOnlyMetadata: { cmuxAccountDeleting: true }, + }]; + + const response = await GET( + new NextRequest("https://cmux.test/api/billing/checkout"), + ); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "https://cmux.test/pricing?billing=account_deletion_in_progress", + ); + expect(createStripeSession).not.toHaveBeenCalled(); + }); + test("blocks Stripe team checkout while account deletion is in progress", async () => { stripeConfigured = true; userResponses = [{ @@ -407,7 +324,6 @@ describe("billing checkout route", () => { ); expect(createStripeCustomer).not.toHaveBeenCalled(); expect(createStripeSession).not.toHaveBeenCalled(); - expect(teamCustomer.createCheckoutUrl).not.toHaveBeenCalled(); }); test("rejects unknown checkout plans", async () => { @@ -422,32 +338,3 @@ describe("billing checkout route", () => { expect(getUser).not.toHaveBeenCalled(); }); }); - -function emptyProductsPage() { - return Object.assign([], { nextCursor: null }); -} - -function activeProProductsPage() { - return Object.assign( - [ - { - id: "pro", - quantity: 1, - subscription: { - cancelAtPeriodEnd: false, - currentPeriodEnd: null, - }, - }, - ], - { nextCursor: null }, - ); -} - -function mockImplementation( - fn: unknown, - implementation: (...args: never[]) => unknown, -) { - (fn as { mockImplementation(next: typeof implementation): void }).mockImplementation( - implementation, - ); -} diff --git a/web/tests/billing-confirm-route.test.ts b/web/tests/billing-confirm-route.test.ts deleted file mode 100644 index 7d78aa53db0b..000000000000 --- a/web/tests/billing-confirm-route.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; -import { NextRequest } from "next/server"; - -// Capture real implementations BY VALUE: bun's mock.module can mutate an -// already-loaded namespace in place, so calling through a captured namespace -// object at delegation time can recurse into the mock itself. -const dbClientModule = await import("../db/client"); -const realCloudDb = dbClientModule.cloudDb; -const realCloseCloudDbForTests = dbClientModule.closeCloudDbForTests; -const realCreateAwsRdsIamPool = dbClientModule.createAwsRdsIamPool; - -const originalSetTimeout = globalThis.setTimeout; -const originalConsoleError = console.error; - -const updates: unknown[] = []; -let stripeRows: unknown[] = []; -let useStubDb = false; -const getUser = mock(async () => confirmUser()); -const appListProducts = mock(async () => emptyProductsPage()); -const userListProducts = mock(async () => emptyProductsPage()); -const stripeLimit = mock(async () => stripeRows); - -mock.module("../app/lib/stack", () => ({ - getStackServerApp: () => ({ getUser, listProducts: appListProducts }), - isStackConfigured: () => true, - stackServerApp: { getUser, listProducts: appListProducts }, -})); - -// bun's mock.module replaces the module for the whole test process, so keep -// every real export other tests import (vm-workflows, vm-db-read-model call -// closeCloudDbForTests during teardown). -mock.module("../db/client", () => ({ - createAwsRdsIamPool: realCreateAwsRdsIamPool, - closeCloudDbForTests: realCloseCloudDbForTests, - cloudDb: () => - useStubDb - ? ({ - select: () => ({ - from: () => ({ - where: () => ({ - limit: stripeLimit, - }), - }), - }), - } as unknown as ReturnType) - : realCloudDb(), -})); - -beforeAll(() => { - useStubDb = true; -}); - -afterAll(() => { - useStubDb = false; -}); - -const { GET } = await import("../app/api/billing/confirm/route"); - -describe("billing confirm route", () => { - beforeEach(() => { - updates.length = 0; - stripeRows = []; - getUser.mockClear(); - getUser.mockResolvedValue(confirmUser()); - appListProducts.mockClear(); - appListProducts.mockResolvedValue(emptyProductsPage()); - userListProducts.mockClear(); - userListProducts.mockResolvedValue(emptyProductsPage()); - stripeLimit.mockClear(); - console.error = mock(() => {}) as unknown as typeof console.error; - globalThis.setTimeout = ((handler: TimerHandler) => { - queueMicrotask(() => { - if (typeof handler === "function") handler(); - }); - return 0 as unknown as ReturnType; - }) as unknown as typeof setTimeout; - }); - - afterEach(() => { - globalThis.setTimeout = originalSetTimeout; - console.error = originalConsoleError; - }); - - test("keeps Stripe-backed Pro metadata when the Stack poll sees no Pro product", async () => { - stripeRows = [{ id: "sub_1" }]; - getUser.mockResolvedValue(confirmUser({ cmuxPlan: "pro", theme: "dark" })); - - const response = await GET( - new NextRequest("https://cmux.test/api/billing/confirm"), - ); - - expect(response.status).toBe(307); - expect(response.headers.get("location")).toBe( - "https://cmux.test/pricing?welcome=success", - ); - expect(appListProducts).toHaveBeenCalledTimes(4); - expect(userListProducts).toHaveBeenCalledTimes(1); - expect(stripeLimit).toHaveBeenCalledTimes(1); - expect(updates).toEqual([]); - }); - - test("clears stale Pro metadata for a genuinely lapsed user", async () => { - getUser.mockResolvedValue(confirmUser({ cmuxPlan: "pro", theme: "dark" })); - - const response = await GET( - new NextRequest("https://cmux.test/api/billing/confirm"), - ); - - expect(response.status).toBe(307); - expect(response.headers.get("location")).toBe( - "https://cmux.test/pricing?welcome=pending", - ); - expect(appListProducts).toHaveBeenCalledTimes(4); - expect(userListProducts).toHaveBeenCalledTimes(1); - expect(stripeLimit).toHaveBeenCalledTimes(1); - expect(updates).toEqual([{ theme: "dark" }]); - }); -}); - -function confirmUser(metadata: unknown = {}) { - return { - id: "user-confirm", - clientReadOnlyMetadata: metadata, - listProducts: userListProducts, - update: async (options: { clientReadOnlyMetadata: unknown }) => { - updates.push(options.clientReadOnlyMetadata); - }, - }; -} - -function emptyProductsPage() { - return Object.assign([], { nextCursor: null }); -} diff --git a/web/tests/billing-plan-route.test.ts b/web/tests/billing-plan-route.test.ts index d35a36210978..24e86838b865 100644 --- a/web/tests/billing-plan-route.test.ts +++ b/web/tests/billing-plan-route.test.ts @@ -9,7 +9,6 @@ const realCreateAwsRdsIamPool = dbClientModule.createAwsRdsIamPool; let stackConfigured = true; let currentUser: ReturnType | null = null; -let stackProductsActive = false; let stripeSubscriptionRows: Array> = []; let stripeSubscriptionResults: Array>> = []; let dbMissing = false; @@ -50,7 +49,6 @@ describe("billing plan route", () => { beforeEach(() => { stackConfigured = true; currentUser = planUser(); - stackProductsActive = false; stripeSubscriptionRows = []; stripeSubscriptionResults = []; dbMissing = false; @@ -67,14 +65,16 @@ describe("billing plan route", () => { expect(response.billingManagement).toBe("stripe"); }); - test("reports external management for Stack Pro without Stripe subscription rows", async () => { - stackProductsActive = true; + test("reports Free for Stack Pro products without Stripe subscription rows", async () => { + currentUser = planUser({ + stackProductGrant: true, + }); const response = await planResponse(); - expect(response.planId).toBe("pro"); - expect(response.isPro).toBe(true); - expect(response.billingManagement).toBe("external"); + expect(response.planId).toBe("free"); + expect(response.isPro).toBe(false); + expect(response.billingManagement).toBe("none"); }); test("reports no billing management for Free users", async () => { @@ -85,15 +85,15 @@ describe("billing plan route", () => { expect(response.billingManagement).toBe("none"); }); - test("falls back to external management for Stack Pro when DB config is missing", async () => { - stackProductsActive = true; + test("does not grant Pro from Stack products when DB config is missing", async () => { + currentUser = planUser({ stackProductGrant: true }); dbMissing = true; const response = await planResponse(); - expect(response.planId).toBe("pro"); - expect(response.isPro).toBe(true); - expect(response.billingManagement).toBe("external"); + expect(response.planId).toBe("free"); + expect(response.isPro).toBe(false); + expect(response.billingManagement).toBe("none"); }); test("reports Stripe management for an active Team subscription row", async () => { @@ -108,7 +108,7 @@ describe("billing plan route", () => { expect(response.teamBillingManagement).toBe("stripe"); }); - test("reports external Team management when team metadata has no Stripe row", async () => { + test("reports no Team management when team metadata has no Stripe row", async () => { currentUser = planUser({ selectedTeam: { id: "team-plan", @@ -119,8 +119,8 @@ describe("billing plan route", () => { const response = await planResponse(); - expect(response.teamPlanId).toBe("team"); - expect(response.teamBillingManagement).toBe("external"); + expect(response.teamPlanId).toBe("free"); + expect(response.teamBillingManagement).toBe("none"); }); test("reports no Team billing management without a billing team", async () => { @@ -138,7 +138,11 @@ async function planResponse() { return response.json() as Promise>; } -function planUser(options: { selectedTeam?: unknown; listTeams?: () => Promise } = {}) { +function planUser(options: { + selectedTeam?: unknown; + listTeams?: () => Promise; + stackProductGrant?: boolean; +} = {}) { return { id: "user-plan", isAnonymous: false, @@ -147,23 +151,7 @@ function planUser(options: { selectedTeam?: unknown; listTeams?: () => Promise []), - listProducts: mock(async () => - Object.assign( - stackProductsActive - ? [ - { - id: "pro", - quantity: 1, - subscription: { - cancelAtPeriodEnd: false, - currentPeriodEnd: null, - }, - }, - ] - : [], - { nextCursor: null }, - ), - ), + stackProductGrant: options.stackProductGrant ?? false, update: mock(async () => undefined), }; } diff --git a/web/tests/billing-portal-route.test.ts b/web/tests/billing-portal-route.test.ts index eaad166af104..6ffe94e4461c 100644 --- a/web/tests/billing-portal-route.test.ts +++ b/web/tests/billing-portal-route.test.ts @@ -15,30 +15,12 @@ const signedInUser = { clientReadOnlyMetadata: {}, selectedTeam: null as null | { id: string; displayName?: string }, listTeams: mock(async () => [] as Array<{ id: string; displayName?: string }>), - listProducts: mock(async () => - Object.assign( - stackProductsActive - ? [ - { - id: "pro", - quantity: 1, - subscription: { - cancelAtPeriodEnd: false, - currentPeriodEnd: null, - }, - }, - ] - : [], - { nextCursor: null }, - ), - ), update: mock(async () => undefined), }; const anonymousUser = { id: "anonymous-pro", isAnonymous: true, clientReadOnlyMetadata: {}, - listProducts: mock(async () => Object.assign([], { nextCursor: null })), update: mock(async () => undefined), }; @@ -48,7 +30,6 @@ let returnNullUser: unknown = signedInUser; let anonymousIfExistsUser: unknown = null; let customerRows: { id: string }[] = [{ id: "cus_123" }]; let stripeSubscriptionRows: { id: string }[] = []; -let stackProductsActive = false; const getUser = mock(async (options?: unknown) => { const or = @@ -118,13 +99,10 @@ describe("billing portal route", () => { anonymousIfExistsUser = null; customerRows = [{ id: "cus_123" }]; stripeSubscriptionRows = []; - stackProductsActive = false; signedInUser.selectedTeam = null; signedInUser.listTeams.mockClear(); getUser.mockClear(); - signedInUser.listProducts.mockClear(); signedInUser.update.mockClear(); - anonymousUser.listProducts.mockClear(); anonymousUser.update.mockClear(); createPortalSession.mockClear(); createPortalSession.mockResolvedValue({ @@ -233,9 +211,8 @@ describe("billing portal route", () => { expect(createPortalSession).not.toHaveBeenCalled(); }); - test("redirects legacy Pro users without a Stripe customer row to external billing", async () => { + test("redirects users without a Stripe customer row to billing unavailable", async () => { customerRows = []; - stackProductsActive = true; const response = await GET( new NextRequest("https://cmux.test/api/billing/portal"), @@ -243,13 +220,13 @@ describe("billing portal route", () => { expect(response.status).toBe(302); expect(response.headers.get("location")).toBe( - "https://cmux.test/pricing?billing=external", + "https://cmux.test/pricing?billing=unavailable", ); expect(captureBillingError).not.toHaveBeenCalled(); expect(createPortalSession).not.toHaveBeenCalled(); }); - test("captures missing customer rows for Stripe-managed users and redirects external", async () => { + test("captures missing customer rows for Stripe-managed users and redirects unavailable", async () => { customerRows = []; stripeSubscriptionRows = [{ id: "sub_123" }]; @@ -259,7 +236,7 @@ describe("billing portal route", () => { expect(response.status).toBe(302); expect(response.headers.get("location")).toBe( - "https://cmux.test/pricing?billing=external", + "https://cmux.test/pricing?billing=unavailable", ); expect(captureBillingError).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/web/tests/billing-pro.test.ts b/web/tests/billing-pro.test.ts index 54ecbf7ecb29..1592de21703a 100644 --- a/web/tests/billing-pro.test.ts +++ b/web/tests/billing-pro.test.ts @@ -1,160 +1,27 @@ import { describe, expect, test } from "bun:test"; import { - PRO_PLAN_ID, - PRO_PRODUCT_ID, FREE_PLAN_ID, - hasActiveProSubscription, + PRO_PLAN_ID, reconcileProPlanMetadata, resolveProPlanStatus, syncProPlanMetadata, } from "../services/billing/pro"; import type { ProMetadataJson } from "../services/billing/pro"; -type ProductInput = { - id: string | null; - quantity?: number; - subscription?: { - cancelAtPeriodEnd: boolean; - currentPeriodEnd: Date | null; - } | null; -}; - -function productsPage(items: ProductInput[], nextCursor: string | null = null) { - const page = items.map((item) => ({ - id: item.id, - quantity: item.quantity ?? 0, - subscription: item.subscription ?? null, - })) as Array<{ - id: string | null; - quantity: number; - subscription: null | { - cancelAtPeriodEnd: boolean; - currentPeriodEnd: Date | null; - }; - }> & { nextCursor: string | null }; - page.nextCursor = nextCursor; - return page; -} - -function customerWithPages( - pages: ReturnType[], -): { - listProducts: (options?: { cursor?: string }) => Promise< - ReturnType - >; - requestedCursors: (string | undefined)[]; -} { - const requestedCursors: (string | undefined)[] = []; - return { - requestedCursors, - listProducts: async (options?: { cursor?: string }) => { - requestedCursors.push(options?.cursor); - return pages[requestedCursors.length - 1] ?? productsPage([]); - }, - }; -} - -describe("hasActiveProSubscription", () => { - test("active subscription counts", async () => { - const customer = customerWithPages([ - productsPage([ - { - id: PRO_PRODUCT_ID, - subscription: { cancelAtPeriodEnd: false, currentPeriodEnd: null }, - }, - ]), - ]); - expect(await hasActiveProSubscription(customer)).toBe(true); - }); - - test("subscription set to cancel at period end still counts", async () => { - const customer = customerWithPages([ - productsPage([ - { - id: PRO_PRODUCT_ID, - subscription: { cancelAtPeriodEnd: true, currentPeriodEnd: null }, - }, - ]), - ]); - expect(await hasActiveProSubscription(customer)).toBe(true); - }); - - test("manual grant (quantity, no subscription) counts", async () => { - const customer = customerWithPages([ - productsPage([{ id: PRO_PRODUCT_ID, quantity: 1 }]), - ]); - expect(await hasActiveProSubscription(customer)).toBe(true); - }); - - test("subscription past its period end does not count", async () => { - const customer = customerWithPages([ - productsPage([ - { - id: PRO_PRODUCT_ID, - subscription: { - cancelAtPeriodEnd: true, - currentPeriodEnd: new Date(Date.now() - 60_000), - }, - }, - ]), - ]); - expect(await hasActiveProSubscription(customer)).toBe(false); - }); - - test("subscription with future period end counts", async () => { - const customer = customerWithPages([ - productsPage([ - { - id: PRO_PRODUCT_ID, - subscription: { - cancelAtPeriodEnd: true, - currentPeriodEnd: new Date(Date.now() + 60_000), - }, - }, - ]), - ]); - expect(await hasActiveProSubscription(customer)).toBe(true); - }); - - test("other products do not count", async () => { - const customer = customerWithPages([ - productsPage([ - { - id: "team", - subscription: { cancelAtPeriodEnd: false, currentPeriodEnd: null }, - }, - { id: PRO_PRODUCT_ID, quantity: 0 }, - ]), - ]); - expect(await hasActiveProSubscription(customer)).toBe(false); - }); - - test("walks pagination cursors until pro is found", async () => { - const customer = customerWithPages([ - productsPage([{ id: "team", quantity: 1 }], "cursor-2"), - productsPage([ - { - id: PRO_PRODUCT_ID, - subscription: { cancelAtPeriodEnd: false, currentPeriodEnd: null }, - }, - ]), - ]); - expect(await hasActiveProSubscription(customer)).toBe(true); - expect(customer.requestedCursors).toEqual([undefined, "cursor-2"]); - }); -}); - type MetadataUser = { + id?: string; clientReadOnlyMetadata?: unknown; update: (options: { clientReadOnlyMetadata: ProMetadataJson; }) => Promise; updates: ProMetadataJson[]; + stackProductGrant?: boolean; }; -function metadataUser(metadata: unknown): MetadataUser { +function metadataUser(metadata: unknown, id?: string): MetadataUser { const updates: ProMetadataJson[] = []; return { + id, clientReadOnlyMetadata: metadata, updates, update: async (options) => { @@ -216,60 +83,62 @@ describe("syncProPlanMetadata", () => { }); describe("reconcileProPlanMetadata", () => { - function reconcileUser(metadata: unknown, products: ProductInput[]) { - const base = metadataUser(metadata); - const pages = customerWithPages([productsPage(products)]); - return { ...base, listProducts: pages.listProducts }; - } - - const activePro: ProductInput = { - id: PRO_PRODUCT_ID, - subscription: { cancelAtPeriodEnd: false, currentPeriodEnd: null }, - }; - - test("upgrades metadata when subscribed but unsynced", async () => { - const user = reconcileUser({}, [activePro]); - expect(await reconcileProPlanMetadata(user)).toBe(true); + test("upgrades metadata when a Stripe subscription row is active", async () => { + const user = metadataUser({}, "user-stripe-pro"); + expect( + await reconcileProPlanMetadata(user, { + hasActiveStripeSubscription: async (stackUserId) => + stackUserId === "user-stripe-pro", + }), + ).toBe(true); expect(user.updates).toEqual([{ cmuxPlan: PRO_PLAN_ID }]); }); - test("clears metadata when subscription lapsed", async () => { - const user = reconcileUser({ cmuxPlan: PRO_PLAN_ID }, []); - expect(await reconcileProPlanMetadata(user)).toBe(true); + test("clears metadata when no Stripe subscription row is active", async () => { + const user = metadataUser({ cmuxPlan: PRO_PLAN_ID }, "user-free"); + expect( + await reconcileProPlanMetadata(user, { + hasActiveStripeSubscription: async () => false, + }), + ).toBe(true); expect(user.updates).toEqual([{}]); }); - test("no-op when already in sync", async () => { - const user = reconcileUser({ cmuxPlan: PRO_PLAN_ID }, [activePro]); - expect(await reconcileProPlanMetadata(user)).toBe(false); + test("ignores Stack product subscriptions when reconciling", async () => { + const user = metadataUser({}, "user-stack-only"); + user.stackProductGrant = true; + + expect( + await reconcileProPlanMetadata(user, { + hasActiveStripeSubscription: async () => false, + }), + ).toBe(false); expect(user.updates).toEqual([]); }); test("skips when manual cmuxVmPlan override is set", async () => { - const user = reconcileUser({ cmuxVmPlan: "enterprise" }, []); - expect(await reconcileProPlanMetadata(user)).toBe(false); + const user = metadataUser({ cmuxVmPlan: "enterprise" }, "user-free"); + expect( + await reconcileProPlanMetadata(user, { + hasActiveStripeSubscription: async () => false, + }), + ).toBe(false); expect(user.updates).toEqual([]); }); }); describe("resolveProPlanStatus", () => { - function statusUser(metadata: unknown, products: ProductInput[], id?: string) { - const base = metadataUser(metadata); - const pages = customerWithPages([productsPage(products)]); - return { ...base, id, listProducts: pages.listProducts }; - } - - const activePro: ProductInput = { - id: PRO_PRODUCT_ID, - subscription: { cancelAtPeriodEnd: false, currentPeriodEnd: null }, - }; - - test("returns pro and syncs metadata for an active subscription", async () => { - const user = statusUser({}, [activePro]); - await expect(resolveProPlanStatus(user)).resolves.toEqual({ + test("returns pro and syncs metadata only for an active Stripe subscription row", async () => { + const user = metadataUser({}, "user-stripe-pro"); + await expect( + resolveProPlanStatus(user, { + hasActiveStripeSubscription: async (stackUserId) => + stackUserId === "user-stripe-pro", + }), + ).resolves.toEqual({ planId: PRO_PLAN_ID, isPro: true, - billingManagement: "external", + billingManagement: "stripe", metadataPlanId: null, hasManualVmPlanOverride: false, metadataChanged: true, @@ -277,9 +146,32 @@ describe("resolveProPlanStatus", () => { expect(user.updates).toEqual([{ cmuxPlan: PRO_PLAN_ID }]); }); - test("returns free and clears stale pro metadata after lapse", async () => { - const user = statusUser({ cmuxPlan: PRO_PLAN_ID }, []); - await expect(resolveProPlanStatus(user)).resolves.toEqual({ + test("Stack product subscriptions do not grant Pro", async () => { + const user = metadataUser({}, "user-stack-only"); + user.stackProductGrant = true; + + await expect( + resolveProPlanStatus(user, { + hasActiveStripeSubscription: async () => false, + }), + ).resolves.toEqual({ + planId: FREE_PLAN_ID, + isPro: false, + billingManagement: "none", + metadataPlanId: null, + hasManualVmPlanOverride: false, + metadataChanged: false, + }); + expect(user.updates).toEqual([]); + }); + + test("returns free and clears stale pro metadata after Stripe lapse", async () => { + const user = metadataUser({ cmuxPlan: PRO_PLAN_ID }, "user-lapsed"); + await expect( + resolveProPlanStatus(user, { + hasActiveStripeSubscription: async () => false, + }), + ).resolves.toEqual({ planId: FREE_PLAN_ID, isPro: false, billingManagement: "none", @@ -291,32 +183,19 @@ describe("resolveProPlanStatus", () => { }); test("does not mutate metadata when a manual VM plan override exists", async () => { - const user = statusUser({ cmuxVmPlan: "enterprise" }, [activePro]); - await expect(resolveProPlanStatus(user)).resolves.toEqual({ - planId: PRO_PLAN_ID, - isPro: true, - billingManagement: "external", - metadataPlanId: null, - hasManualVmPlanOverride: true, - metadataChanged: false, - }); - expect(user.updates).toEqual([]); - }); - - test("returns pro when Stripe has an active subscription row", async () => { - const user = statusUser({}, [], "user-stripe-pro"); + const user = metadataUser({ cmuxVmPlan: "enterprise" }, "user-stripe-pro"); await expect( resolveProPlanStatus(user, { - hasActiveStripeSubscription: async (stackUserId) => stackUserId === "user-stripe-pro", + hasActiveStripeSubscription: async () => true, }), ).resolves.toEqual({ planId: PRO_PLAN_ID, isPro: true, billingManagement: "stripe", metadataPlanId: null, - hasManualVmPlanOverride: false, - metadataChanged: true, + hasManualVmPlanOverride: true, + metadataChanged: false, }); - expect(user.updates).toEqual([{ cmuxPlan: PRO_PLAN_ID }]); + expect(user.updates).toEqual([]); }); }); diff --git a/web/tests/dashboard-billing-page.test.tsx b/web/tests/dashboard-billing-page.test.tsx index 4e858ce9ff6a..83917c5a55cf 100644 --- a/web/tests/dashboard-billing-page.test.tsx +++ b/web/tests/dashboard-billing-page.test.tsx @@ -10,7 +10,6 @@ const realCreateAwsRdsIamPool = dbClientModule.createAwsRdsIamPool; let stackConfigured = true; let currentUser: typeof proUser | null = null; -let stackProductsActive = false; let subscriptionRows: Array> = []; let subscriptionResults: Array>> = []; let customerRows: Array> = []; @@ -22,23 +21,6 @@ const proUser = { clientReadOnlyMetadata: {}, selectedTeam: null as null | { id: string; displayName?: string; clientReadOnlyMetadata?: unknown }, listTeams: mock(async () => [] as Array<{ id: string; displayName?: string; clientReadOnlyMetadata?: unknown }>), - listProducts: mock(async () => - Object.assign( - stackProductsActive - ? [ - { - id: "pro", - quantity: 1, - subscription: { - cancelAtPeriodEnd: false, - currentPeriodEnd: new Date("2026-12-01T00:00:00Z"), - }, - }, - ] - : [], - { nextCursor: null }, - ), - ), update: mock(async () => undefined), }; @@ -84,14 +66,13 @@ describe("dashboard billing page", () => { beforeEach(() => { stackConfigured = true; currentUser = proUser; - stackProductsActive = false; subscriptionRows = []; subscriptionResults = []; customerRows = []; + proUser.clientReadOnlyMetadata = {}; proUser.selectedTeam = null; proUser.listTeams.mockClear(); mockImplementation(proUser.listTeams, async () => []); - proUser.listProducts.mockClear(); proUser.update.mockClear(); }); @@ -190,15 +171,13 @@ describe("dashboard billing page", () => { expect(html).not.toContain("Upgrade when you need cloud agents or team billing."); }); - test("renders legacy Stack Pro without Stripe self-serve actions", async () => { - stackProductsActive = true; + test("renders Stack metadata-only Pro as Free", async () => { + proUser.clientReadOnlyMetadata = { cmuxPlan: "pro" }; const html = await renderBillingPage(); - expect(html).toContain("cmux Pro"); - expect(html).toContain( - "Your subscription is managed by our previous billing system. Contact support to make changes.", - ); + expect(html).toContain("Free"); + expect(html).toContain("You are currently on the Free plan."); expect(html).not.toContain("/api/billing/subscription"); expect(html).not.toContain("/api/billing/portal"); }); diff --git a/web/tests/helpers/testflight-user.ts b/web/tests/helpers/testflight-user.ts index d14230d8dfe8..574da711a1a7 100644 --- a/web/tests/helpers/testflight-user.ts +++ b/web/tests/helpers/testflight-user.ts @@ -1,5 +1,3 @@ -import { mock } from "bun:test"; - const testflightEligibilityKey = Symbol.for("cmux.tests.testflightEligibility"); export function createTestflightUser({ @@ -11,24 +9,7 @@ export function createTestflightUser({ primaryEmail: "Pro@Example.com", displayName: "Pro User", clientReadOnlyMetadata: {}, - listProducts: mock(async () => - Object.assign( - eligible - ? [ - { - id: "pro", - quantity: 1, - subscription: { - cancelAtPeriodEnd: false, - currentPeriodEnd: null, - }, - }, - ] - : [], - { nextCursor: null }, - ), - ), - update: mock(async () => undefined), + update: async () => undefined, }; Object.defineProperty(user, testflightEligibilityKey, { value: eligible, diff --git a/web/tests/pricing-page.test.tsx b/web/tests/pricing-page.test.tsx index 9e4afd58fd1d..9a44cad59623 100644 --- a/web/tests/pricing-page.test.tsx +++ b/web/tests/pricing-page.test.tsx @@ -9,30 +9,12 @@ const realCloseCloudDbForTests = dbClientModule.closeCloudDbForTests; const realCreateAwsRdsIamPool = dbClientModule.createAwsRdsIamPool; let stackConfigured = false; -let proActive = false; let stripeSubscriptionRows: Array> = []; const proUser = { id: "user-pro", isAnonymous: false, primaryEmail: "pro@example.com", clientReadOnlyMetadata: { cmuxPlan: "pro" }, - listProducts: mock(async () => - Object.assign( - proActive - ? [ - { - id: "pro", - quantity: 1, - subscription: { - cancelAtPeriodEnd: false, - currentPeriodEnd: null, - }, - }, - ] - : [], - { nextCursor: null }, - ), - ), update: mock(async () => undefined), }; const getUser = mock(async () => proUser); @@ -82,10 +64,8 @@ const { default: PricingPage } = await import("../app/[locale]/pricing/page"); describe("localized pricing page", () => { beforeEach(() => { stackConfigured = false; - proActive = false; stripeSubscriptionRows = []; getUser.mockClear(); - proUser.listProducts.mockClear(); proUser.update.mockClear(); }); @@ -97,23 +77,20 @@ describe("localized pricing page", () => { expect(html).not.toContain("Manage billing"); }); - test("renders the external billing note without a portal link for Stack Pro snapshots", async () => { + test("renders Stack metadata-only Pro snapshots as Free", async () => { stackConfigured = true; - proActive = true; const element = await PricingPage({ params: Promise.resolve({ locale: "en" }) }); const html = renderToStaticMarkup(element); expect(html).not.toContain('href="/api/billing/portal"'); - expect(html).toContain( - "Your subscription is managed by our previous billing system. Contact support to make changes.", - ); - expect(html).toContain("Current plan"); + // PRO_CHECKOUT_URL appends the external-browser intent param, so match the + // path prefix rather than an exact href. + expect(html).toContain("/api/billing/checkout?plan=pro"); }); test("renders Manage billing for Stripe-managed Pro snapshots", async () => { stackConfigured = true; - proActive = true; stripeSubscriptionRows = [{ id: "sub_123" }]; const element = await PricingPage({ params: Promise.resolve({ locale: "en" }) }); diff --git a/web/tests/testflight-route.test.ts b/web/tests/testflight-route.test.ts index 83a03b7d066e..157057babf5e 100644 --- a/web/tests/testflight-route.test.ts +++ b/web/tests/testflight-route.test.ts @@ -1,12 +1,16 @@ import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; import { NextRequest } from "next/server"; -import { createTestflightUser } from "./helpers/testflight-user"; +import { + createTestflightUser, + testflightUserEligibility, +} from "./helpers/testflight-user"; const dbClientModule = await import("../db/client"); const realCloudDb = dbClientModule.cloudDb; const realCloseCloudDbForTests = dbClientModule.closeCloudDbForTests; const realCreateAwsRdsIamPool = dbClientModule.createAwsRdsIamPool; +const billingProModule = await import("../services/billing/pro"); let stackConfigured = true; let ascConfigured = true; @@ -30,6 +34,14 @@ const ascFetch = mock(async (path: unknown) => { return {}; }); const captureAscError = mock(() => undefined); +const isTestflightEligible = mock(async (candidate: unknown) => + testflightUserEligibility(candidate) ?? false, +); + +mock.module("../services/billing/pro", () => ({ + ...billingProModule, + isTestflightEligible, +})); mock.module("../app/lib/stack", () => ({ getStackServerApp: () => ({ getUser }), diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index 99262362c539..c0cf30da1a76 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -558,7 +558,6 @@ describe("VM REST auth", () => { clientReadOnlyMetadata: { cmuxVmPlan: "pro" }, }, listTeams, - listProducts: async () => Object.assign([], { nextCursor: null }), }); runVmWorkflow.mockResolvedValue({ providerVmId: "provider-vm-body-team",