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
2 changes: 1 addition & 1 deletion .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@
797 Sources/ClosedItemHistory.swift
779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift
773 Sources/App/MenuBarExtraController.swift
773 Sources/PricingPlansScreen.swift
769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift
768 Sources/PricingPlansScreen.swift
768 cmuxUITests/BrowserFixtureInteractionUITests.swift
762 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift
756 Sources/Panels/AgentSessionWebRendererCoordinator.swift
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,17 @@ public protocol AccountFlow: AnyObject {
/// point at a local web server.
func openProUpgrade()

/// Re-fetches the billing plan state used by the Pro account row.
func refreshBillingPlan() async

/// Opens the hosted Stripe customer portal in the user's default browser.
func openBillingPortal()

/// Whether the Pro upgrade row should render. The host backs this with
/// a remotely toggleable feature flag; `true` when flags are
/// unavailable only in explicit dogfood builds whose flag default is on.
var isProUpgradeAvailable: Bool { get }

/// Whether the current account has an active Pro entitlement.
var isProActive: Bool { get }
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ import SwiftUI
///
/// Shows the cmux Pro pitch (one title line + one price/value subtitle)
/// with a trailing button that asks the host to open the pricing page in
/// the default browser via ``AccountFlow/openProUpgrade()``. Stateless:
/// the app does not track plan state yet, so the row renders for every
/// user.
/// the default browser via ``AccountFlow/openProUpgrade()`` or the billing
/// portal via ``AccountFlow/openBillingPortal()`` for active subscribers.
@MainActor
struct ProUpgradeCard: View {
let flow: AccountFlow?
Expand All @@ -21,23 +20,47 @@ struct ProUpgradeCard: View {
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "settings.account.pro.title", defaultValue: "cmux Pro"))
.cmuxFont(size: 13, weight: .medium)
Text(String(
localized: "settings.account.pro.subtitle",
defaultValue: "Cloud dev boxes, the iOS app, and cmux AI. $30/month, or $240/year."
))
.cmuxFont(size: 11)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(subtitleText)
.cmuxFont(size: 11)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 12)
Button {
flow?.openProUpgrade()
if flow?.isProActive == true {
flow?.openBillingPortal()
} else {
flow?.openProUpgrade()
}
} label: {
Text(String(localized: "settings.account.pro.upgrade", defaultValue: "Upgrade…"))
Text(buttonTitle)
}
.controlSize(.small)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.task(id: flow?.currentIdentity?.id ?? "") {
await flow?.refreshBillingPlan()
}
}

private var subtitleText: String {
if flow?.isProActive == true {
return String(
localized: "settings.account.pro.activeSubtitle",
defaultValue: "Your Pro subscription is active. Manage billing or cancel in Stripe."
)
}
return String(
localized: "settings.account.pro.subtitle",
defaultValue: "Cloud dev boxes, the iOS app, and cmux AI. $30/month, or $240/year."
)
}

private var buttonTitle: String {
if flow?.isProActive == true {
return String(localized: "settings.account.pro.manageBilling", defaultValue: "Manage billing")
}
return String(localized: "settings.account.pro.upgrade", defaultValue: "Upgrade…")
}
}
34 changes: 34 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -127462,6 +127462,40 @@
}
}
},
"settings.account.pro.activeSubtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Your Pro subscription is active. Manage billing or cancel in Stripe."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Pro サブスクリプションは有効です。Stripe で請求管理やキャンセルができます。"
}
}
}
},
"settings.account.pro.manageBilling": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Manage billing"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "請求を管理"
}
}
}
},
"settings.account.pro.subtitle": {
"extractionState": "manual",
"localizations": {
Expand Down
8 changes: 8 additions & 0 deletions Sources/Auth/AuthEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ enum AuthEnvironment {
)
}

static var billingPortalURL: URL {
resolvedBillingPortalURL(environment: ProcessInfo.processInfo.environment)
}

static func resolvedBillingPortalURL(environment: [String: String]) -> URL {
billingWebsiteOrigin(environment: environment).appendingPathComponent("api/billing/portal")
}

static var signInWebsiteOrigin: URL {
canonicalizedLoopbackURL(
resolvedURL(
Expand Down
38 changes: 38 additions & 0 deletions Sources/Auth/HostAccountFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ final class HostAccountFlow: AccountFlow {
private let featureFlags = CmuxFeatureFlags.shared
@ObservationIgnored private var featureFlagsObserver: (any NSObjectProtocol)?
private(set) var isProUpgradeAvailable: Bool
private(set) var isProActive = false

init(coordinator: AuthCoordinator, browserSignIn: HostBrowserSignInFlow) {
self.coordinator = coordinator
Expand Down Expand Up @@ -78,6 +79,7 @@ final class HostAccountFlow: AccountFlow {

func signOut() async {
await browserSignIn.signOut()
isProActive = false
}

func refreshCurrentUser() async {
Expand All @@ -86,10 +88,42 @@ final class HostAccountFlow: AccountFlow {
// stale the user signs in again (full browser round trip).
}

func refreshBillingPlan() async {
guard coordinator.currentUser != nil else {
isProActive = false
return
}
var request = URLRequest(url: AuthEnvironment.apiBaseURL.appendingPathComponent("api/billing/plan"))
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")

if let tokens = try? await coordinator.currentTokens() {
request.setValue("Bearer \(tokens.accessToken)", forHTTPHeaderField: "Authorization")
request.setValue(tokens.refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token")
}

do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
isProActive = false
return
}
let decoded = try JSONDecoder().decode(BillingPlanResponse.self, from: data)
isProActive = decoded.isPro
} catch {
isProActive = false
}
}
Comment on lines +91 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider adding unit coverage for refreshBillingPlan()'s branches.

This method decides whether the UI shows "Upgrade…" vs "Manage billing" and which action openBillingPortal()/openProUpgrade() fires — a correctness-critical entitlement check — but there's no test exercising the no-user / non-2xx / decode-success / thrown-error paths (only AuthEnvironmentTests covers URL resolution, not this fetch/decode logic).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Auth/HostAccountFlow.swift` around lines 91 - 117, Add unit tests for
HostAccountFlow.refreshBillingPlan() to cover each decision path: no currentUser
should immediately set isProActive false, a non-2xx HTTPURLResponse from
URLSession.shared.data(for:) should also clear it, a successful 2xx response
should decode BillingPlanResponse and set isProActive from decoded.isPro, and
any thrown error should fall back to false. Use the refreshBillingPlan() method
and its isProActive state changes as the main assertions, and mock
coordinator.currentTokens() plus the URLSession response/data to exercise these
branches.


func openProUpgrade() {
ProUpgradePresenter.present()
}

func openBillingPortal() {
ProUpgradePresenter.presentBillingPortal()
}

private static func identity(from user: CMUXAuthUser?) -> AccountIdentity? {
guard let user else { return nil }
return AccountIdentity(
Expand All @@ -100,3 +134,7 @@ final class HostAccountFlow: AccountFlow {
)
}
}

private struct BillingPlanResponse: Decodable {
let isPro: Bool
}
5 changes: 5 additions & 0 deletions Sources/PricingPlansScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ enum ProUpgradePresenter {
NSWorkspace.shared.open(AuthEnvironment.billingCheckoutURL)
}

@MainActor
static func presentBillingPortal() {
NSWorkspace.shared.open(AuthEnvironment.billingPortalURL)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Authenticate native portal opens

When a Pro user is signed in only in the native app, this opens /api/billing/portal in the default browser without carrying the app's Stack tokens. The Settings row just determined Pro state via refreshBillingPlan() using bearer tokens, but the new portal route resolves the customer only from the browser Stack session/anonymous cookie (getUser in web/app/api/billing/portal/route.ts), so a browser with no matching cookie or a different account redirects to pricing/unavailable instead of Stripe and leaves the user unable to manage or cancel from Settings. Please create the portal session through an authenticated native request or otherwise bind this open to the current app user.

Useful? React with 👍 / 👎.

}

@MainActor
private static func presentBrowserSplit(url: URL, transparentBackground: Bool) {
// Preferred: a browser split to the right of the focused pane, so the
Expand Down
23 changes: 23 additions & 0 deletions cmuxTests/AuthEnvironmentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,29 @@ struct AuthEnvironmentTests {
.contains(where: { $0.name == "cmux_scheme" && $0.value == "cmux-dev-preview" }) == true)
}

@Test("billing portal follows app web origin unless billing origin is explicit")
func billingPortalFollowsAppWebOriginUnlessBillingOriginIsExplicit() {
let appOriginURL = AuthEnvironment.resolvedBillingPortalURL(
environment: [
"CMUX_WWW_ORIGIN": "http://127.0.0.1:4278",
]
)
#expect(appOriginURL.scheme == "http")
#expect(appOriginURL.host == "localhost")
#expect(appOriginURL.port == 4278)
#expect(appOriginURL.path == "/api/billing/portal")

let overrideURL = AuthEnvironment.resolvedBillingPortalURL(
environment: [
"CMUX_WWW_ORIGIN": "http://localhost:4278",
"CMUX_BILLING_WWW_ORIGIN": "https://billing-preview.example",
]
)
#expect(overrideURL.scheme == "https")
#expect(overrideURL.host == "billing-preview.example")
#expect(overrideURL.path == "/api/billing/portal")
}

@Test("billing checkout default origin follows build web origin")
func billingCheckoutDefaultOriginFollowsBuildWebOrigin() {
let url = AuthEnvironment.resolvedBillingCheckoutURL(
Expand Down
1 change: 1 addition & 0 deletions skills/cmux-billing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Use this skill before changing billing, pricing, Stripe, Pro entitlement, checko
## Architecture Map

- `/api/billing/checkout` creates Stripe Checkout Sessions for Pro when `STRIPE_SECRET_KEY` is set. It sets `client_reference_id` to the Stack user id, auto-creates an anonymous Stack user for signed-out buyers, and falls back to the legacy Stack purchase path when Stripe is unset or `plan=team`.
- `/api/billing/portal` resolves the current Stack user, looks up their `stripe_customers` row, and creates a Stripe customer portal session returning to `/pricing`.
- `web/services/billing/purchase.ts` is the shared idempotent recorder used by both `/api/billing/complete` and `/api/stripe/webhook`. It attaches email to the purchaser, records `billing_email_claims` on conflict, and never cross-grants based on an unverified email.
- `cmuxPlan` in Stack `clientReadOnlyMetadata` is the only entitlement VM code reads. `cmuxVmPlan` manual override wins.
- `resolveProPlanStatus` ORs legacy Stack products with active `stripe_subscriptions` DB rows.
Expand Down
72 changes: 58 additions & 14 deletions web/app/[locale]/pricing/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { Suspense } from "react";
import { SiteHeader } from "../components/site-header";
import { ProCtaLink } from "../components/pro-cta-link";
import { ProWelcomeBanner } from "../components/pro-welcome-banner";
import { PRO_CHECKOUT_URL, TEAM_CHECKOUT_URL } from "../../lib/billing";
import { DOWNLOAD_CONFIRMATION_HREF } from "../../lib/download";
import { getStackServerApp, isStackConfigured } from "../../lib/stack";
import { resolveProPlanStatus } from "../../../services/billing/pro";
import { buildAlternates } from "../../../i18n/seo";
import {
CurrentPlanBadge,
DisabledButton,
FeatureList,
PlanCard,
PricingCompareTable,
Expand All @@ -27,6 +30,9 @@ import {
// flag inside <ProCtaLink> (see app/lib/feature-flags.ts); the download
// link is the safe fallback.
const ENTERPRISE_CTA_URL = "/enterprise";
const ANONYMOUS_IF_EXISTS = "anonymous-if-exists[deprecated]" as const;

export const dynamic = "force-dynamic";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## pricing page\n'
sed -n '1,220p' web/app/'[locale]'/pricing/page.tsx | cat -n

printf '\n## billing pro service\n'
sed -n '1,260p' web/services/billing/pro.ts | cat -n

printf '\n## search for related symbols\n'
rg -n "currentPlanSnapshot|resolveProPlanStatus|syncProPlanMetadata|captureBillingError|getStackServerApp\(\)\.getUser" web -S

Repository: manaflow-ai/cmux

Length of output: 25473


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '## pricing page'
sed -n '1,220p' web/app/'[locale]'/pricing/page.tsx | cat -n

echo
echo '## billing pro service'
sed -n '1,260p' web/services/billing/pro.ts | cat -n

echo
echo '## symbol search'
rg -n "currentPlanSnapshot|resolveProPlanStatus|syncProPlanMetadata|captureBillingError|getStackServerApp\(\)\.getUser" web -S

Repository: manaflow-ai/cmux

Length of output: 25459


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in web/app/'[locale]'/pricing/page.tsx web/services/billing/pro.ts; do
  echo "===== $f ====="
  awk 'NR>=1 && NR<=260 { print NR ":" $0 }' "$f"
  echo
done

Repository: manaflow-ai/cmux

Length of output: 19002


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '260,310p' web/app/'[locale]'/pricing/page.tsx | cat -n

Repository: manaflow-ai/cmux

Length of output: 943


Guard currentPlanSnapshot() on /pricing getStackServerApp().getUser() and resolveProPlanStatus() can throw and take down the public page, and resolveProPlanStatus() may call user.update() when metadata drifts. Wrap this in try/catch and fail closed to { isPro: false }.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/`[locale]/pricing/page.tsx at line 35, The pricing page’s current
plan lookup is not protected from server-side failures, so
`currentPlanSnapshot()` can crash `/pricing` when
`getStackServerApp().getUser()` or `resolveProPlanStatus()` throws. Update
`currentPlanSnapshot()` to wrap the user/pro status resolution in a try/catch,
handle any `user.update()`-triggered metadata drift errors safely, and return a
fail-closed result of `{ isPro: false }` on failure.


export async function generateMetadata({
params,
Expand All @@ -42,8 +48,14 @@ export async function generateMetadata({
};
}

export default function PricingPage() {
const t = useTranslations("pricing");
export default async function PricingPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "pricing" });
const snapshot = await currentPlanSnapshot();

const freeFeatures = t.raw("free.features") as string[];
const proBaseFeatures = t.raw("pro.features") as string[];
Expand Down Expand Up @@ -96,10 +108,24 @@ export default function PricingPage() {
name={t("pro.name")}
price={t("pro.price")}
period={t("perMonth")}
badge={
snapshot.isPro ? (
<CurrentPlanBadge>{t("currentPlan")}</CurrentPlanBadge>
) : null
}
>
<ProCtaLink checkoutHref={PRO_CHECKOUT_URL} fallbackHref={DOWNLOAD_CONFIRMATION_HREF}>
{t("pro.cta")}
</ProCtaLink>
{snapshot.isPro ? (
<div className="space-y-2">
<DisabledButton>{t("currentPlan")}</DisabledButton>
<SecondaryLink href="/api/billing/portal">
{t("manageBilling")}
Comment on lines +117 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Gate the Stripe portal on Stripe-backed Pro state

When a Pro entitlement comes from the legacy Stack product path (the checkout route still supports Stack fallback, and resolveProPlanStatus treats listProducts() as active Pro), snapshot.isPro is true here even though there is no stripe_customers row. The new /api/billing/portal route only looks up stripeCustomers and redirects to billing=unavailable when that row is absent, so existing Stack-backed Pro subscribers now see a Manage billing link that cannot manage or cancel their subscription. This branch needs to distinguish Stripe-backed Pro from legacy Stack Pro, or provide the appropriate Stack billing fallback.

Useful? React with 👍 / 👎.

</SecondaryLink>
</div>
) : (
<ProCtaLink checkoutHref={PRO_CHECKOUT_URL} fallbackHref={DOWNLOAD_CONFIRMATION_HREF}>
{t("pro.cta")}
</ProCtaLink>
)}
<p className="mt-5 text-sm font-medium">{t("pro.featuresLead")}</p>
<FeatureList items={proFeatures} />
</PlanCard>
Expand Down Expand Up @@ -156,14 +182,18 @@ export default function PricingPage() {
</PrimaryLink>
),
pro: (
<ProCtaLink
checkoutHref={PRO_CHECKOUT_URL}
fallbackHref={DOWNLOAD_CONFIRMATION_HREF}
size="compact"
location="pricing_compare_header"
>
{t("pro.cta")}
</ProCtaLink>
snapshot.isPro ? (
<DisabledButton size="compact">{t("currentPlan")}</DisabledButton>
) : (
<ProCtaLink
checkoutHref={PRO_CHECKOUT_URL}
fallbackHref={DOWNLOAD_CONFIRMATION_HREF}
size="compact"
location="pricing_compare_header"
>
{t("pro.cta")}
</ProCtaLink>
)
),
team: (
<PrimaryLink href={TEAM_CHECKOUT_URL} size="compact">
Expand Down Expand Up @@ -239,3 +269,17 @@ export default function PricingPage() {
</div>
);
}

async function currentPlanSnapshot(): Promise<{ isPro: boolean }> {
if (!isStackConfigured()) {
return { isPro: false };
}

const user = await getStackServerApp().getUser({ or: ANONYMOUS_IF_EXISTS });
if (!user) {
return { isPro: false };
}

const status = await resolveProPlanStatus(user);
return { isPro: status.isPro };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Signed-in users miss Pro UI

High Severity

The new currentPlanSnapshot resolves the viewer with only anonymous-if-exists, so a normal Stack session from pricing-page cookies is skipped. Signed-in Pro users keep seeing the upgrade CTA instead of “Current plan” and “Manage billing”, unlike /api/billing/portal, which tries return-null first.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c738561. Configure here.

Comment on lines +272 to +285

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Duplicate currentPlanSnapshot() implementation across pages.

Per the provided graph context, web/app/app-pricing/page.tsx defines an identical currentPlanSnapshot() (same body, same isStackConfigured/ANONYMOUS_IF_EXISTS/resolveProPlanStatus sequence). This is exactly the kind of duplicated correctness-critical "isPro" resolution the reliability/single-source-of-truth guideline warns about — two independent copies can silently drift when one is patched (e.g., the error-handling fix above) and not the other.

Extract this into a single shared helper (e.g. services/billing/pro.ts or a small lib/current-plan.ts) and have both pages call it.

As per coding guidelines, "For correctness-critical decisions in this PR... ensure you use a single authoritative, structured source of truth rather than name/title heuristics or 'best effort' fallbacks... When you have multiple possible sources... make one the authority."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/`[locale]/pricing/page.tsx around lines 272 - 285, The
`currentPlanSnapshot()` logic is duplicated in multiple pricing pages and should
be moved to a single shared source of truth. Extract the existing
`isStackConfigured`/`ANONYMOUS_IF_EXISTS`/`resolveProPlanStatus` flow from
`currentPlanSnapshot()` into one reusable helper (for example, a shared
billing/pro or lib helper), then update both pricing page implementations to
call that helper instead of keeping separate copies.

Source: Path instructions

Loading
Loading