Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions web/app/[locale]/components/pro-welcome-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Suspense> (useSearchParams requirement).
export function ProWelcomeBanner() {
const t = useTranslations("pricing");
Expand All @@ -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 (
Expand All @@ -44,7 +42,7 @@ export function ProWelcomeBanner() {
<>
{" "}
<a
href="/api/billing/confirm"
href="/pricing"
className="underline underline-offset-2 decoration-link-underline hover:decoration-foreground transition-colors"
>
{t("welcomePendingAction")}
Expand Down
11 changes: 1 addition & 10 deletions web/app/[locale]/dashboard/billing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export default async function DashboardBillingPage({
canManageBilling={hasStripeCustomer}
/>
) : (
<LegacyPlan t={t} />
<FreePlan t={t} />
)}

{billingTeam && teamSubscription ? (
Expand Down Expand Up @@ -442,15 +442,6 @@ function TeamPlan({
);
}

function LegacyPlan({ t }: { t: Awaited<ReturnType<typeof getTranslations>> }) {
return (
<section className="border border-border p-3">
<h2 className="text-sm font-medium">{t("pro.name")}</h2>
<p className="mt-2 max-w-2xl text-muted">{t("legacy.body")}</p>
</section>
);
}

function BillingMetric({ label, value }: { label: string; value: string }) {
return (
<div className="border-b border-border p-3 sm:border-b-0 sm:border-r">
Expand Down
16 changes: 5 additions & 11 deletions web/app/[locale]/pricing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export default async function PricingPage({
<SiteHeader />

<main className="w-full max-w-6xl mx-auto px-6 py-16 sm:py-20">
{/* Post-checkout / billing states from /api/billing/checkout|confirm */}
{/* Post-checkout / billing states from /api/billing/checkout */}
<Suspense fallback={null}>
<ProWelcomeBanner />
</Suspense>
Expand Down Expand Up @@ -117,15 +117,9 @@ export default async function PricingPage({
{snapshot.isPro ? (
<div className="space-y-2">
<DisabledButton>{t("currentPlan")}</DisabledButton>
{snapshot.billingManagement === "stripe" ? (
<SecondaryLink href="/api/billing/portal">
{t("manageBilling")}
</SecondaryLink>
) : (
<p className="text-sm leading-6 text-muted">
{t("billingExternal")}
</p>
)}
<SecondaryLink href="/api/billing/portal">
{t("manageBilling")}
</SecondaryLink>
</div>
) : (
<ProCtaLink checkoutHref={PRO_CHECKOUT_URL} fallbackHref={DOWNLOAD_CONFIRMATION_HREF}>
Expand Down Expand Up @@ -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" };
Expand Down
87 changes: 10 additions & 77 deletions web/app/api/billing/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,9 +24,7 @@ export const dynamic = "force-dynamic";
type CheckoutStackServerApp = StackServerApp<true>;

// 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({
Expand All @@ -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(
Expand Down Expand Up @@ -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),
Expand All @@ -262,10 +205,6 @@ type CheckoutTeamCustomer = {
readonly id?: string;
readonly displayName?: string | null;
listUsers?(): Promise<readonly unknown[]>;
createCheckoutUrl(options: {
productId: string;
returnUrl?: string;
}): Promise<string>;
};

type CheckoutTeamUser = {
Expand Down Expand Up @@ -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;
Expand Down
62 changes: 0 additions & 62 deletions web/app/api/billing/confirm/route.ts

This file was deleted.

11 changes: 4 additions & 7 deletions web/app/api/billing/plan/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -32,6 +32,7 @@ export async function GET(request: NextRequest) {
});
}

const billingAvailable = isStripeBillingConfigured();
const stackServerApp = getStackServerApp();
const bearer = parseBearer(request);
const user = bearer
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -90,12 +91,8 @@ async function resolveTeamPlanStatus(user: BillingTeamUserLike): Promise<TeamPla
return { planId: FREE_PLAN_ID, billingManagement: "none" };
}
const stripeActive = await hasActiveTeamSubscriptionForTeam(team.id);
const metadataActive = metadataPlanId(team.clientReadOnlyMetadata) === TEAM_PLAN_ID;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Team VM metadata entitlement drift

Medium Severity

Team plan status now depends only on an active stripe_subscriptions row, but Cloud VM billing still treats a team’s clientReadOnlyMetadata.cmuxPlan as authoritative. Legacy Stack-only teams can show as Free in billing APIs and UI while VM create still resolves paid team limits from stale metadata.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 526da14. Configure here.

if (stripeActive) {
return { planId: TEAM_PLAN_ID, billingManagement: "stripe" };
}
if (metadataActive) {
return { planId: TEAM_PLAN_ID, billingManagement: "external" };
}
return { planId: FREE_PLAN_ID, billingManagement: "none" };
}
4 changes: 2 additions & 2 deletions web/app/api/billing/portal/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export async function GET(request: NextRequest) {
},
);
}
return pricingRedirect(request, "external");
return pricingRedirect(request, "unavailable");
}

const session = await stripe().billingPortal.sessions.create({
Expand Down Expand Up @@ -101,7 +101,7 @@ function billingPortalScope(raw: string | null): "user" | "team" {
return raw === "team" ? "team" : "user";
}

function pricingRedirect(request: NextRequest, billing: "unavailable" | "external" | "error") {
function pricingRedirect(request: NextRequest, billing: "unavailable" | "error") {
return NextResponse.redirect(new URL(`/pricing?billing=${billing}`, request.url), 302);
}

Expand Down
5 changes: 2 additions & 3 deletions web/app/api/vm/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,7 @@ export async function POST(request: Request): Promise<Response> {
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.
Expand Down Expand Up @@ -369,7 +368,7 @@ export async function POST(request: Request): Promise<Response> {
}

// 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;
Expand Down
Loading