Skip to content

Add Stripe customer portal for Pro billing management - #7443

Merged
lawrencecchen merged 3 commits into
mainfrom
feat-billing-portal
Jul 6, 2026
Merged

lawrencecchen merged 3 commits into
mainfrom
feat-billing-portal

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Customers need self-serve cancel/manage before the pricing flag flips on. GET /api/billing/portal creates a Stripe billing portal session for the current Stack user (customer id strictly from the stripe_customers row for the resolved stackUserId, anonymous purchaser fallback included) and redirects; failures land on safe /pricing banners with Sentry capture. Pro users get Manage billing links on the success page, /app-pricing, the localized pricing page, and the native Settings Account card via the shared presenter, with the portal URL following the same CMUX_BILLING_WWW_ORIGIN-then-app-web-origin resolution as checkout.

/fable loop: two judge rounds (origin-override parity + anonymous-fallback test coverage), 377 web tests green in sorted order, typecheck clean, en+ja localization audited.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Touches billing auth, Stripe customer lookup, and entitlement-driven UI across web and native; failures are mostly redirects, but wrong customer mapping or plan detection would affect who can manage subscriptions.

Overview
Adds self-serve Stripe billing management via a new GET /api/billing/portal route that resolves the Stack user (including anonymous purchaser fallback), loads stripe_customers for that user only, creates a portal session, and redirects—or sends users to /pricing?billing=… on failure with Sentry context.

Web pricing is now plan-aware: localized /pricing and /app-pricing use resolveProPlanStatus to show a current-plan badge, disable upgrade CTAs for Pro, and link Manage billing to the portal; post-checkout /billing/success gets the same link. New currentPlan / manageBilling strings (en + ja).

macOS: AccountFlow gains isProActive, refreshBillingPlan() (api/billing/plan), and openBillingPortal(); ProUpgradeCard refreshes on identity change and switches copy/button between Upgrade and Manage billing. AuthEnvironment.billingPortalURL mirrors checkout origin rules (CMUX_BILLING_WWW_ORIGIN); ProUpgradePresenter.presentBillingPortal() opens it in the default browser.

Tests cover the portal route (signed-in, anonymous fallback, misconfig), pricing pages, and portal URL resolution; billing skill doc updated.

Reviewed by Cursor Bugbot for commit cb8d102. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Adds a self-serve Stripe customer portal so Pro users can manage billing or cancel. Includes a new API route, “Manage billing” links across web and macOS, and safe fallbacks with correct origin resolution.

  • New Features

    • Added GET /api/billing/portal: resolves the current Stack user (with anonymous purchaser fallback), looks up their stripe_customers by stackUserId, creates a Stripe Billing Portal session with return to /pricing, and 302s; unavailable/error paths redirect to /pricing?billing=… with error capture.
    • Web: For active Pro, show “Current plan” and a “Manage billing” link on /billing/success, /app-pricing, and localized /[locale]/pricing (server-rendered via resolveProPlanStatus).
    • macOS: AccountFlow adds refreshBillingPlan(), openBillingPortal(), and isProActive; ProUpgradeCard auto-refreshes plan state and switches Upgrade vs Manage; ProUpgradePresenter.presentBillingPortal() opens AuthEnvironment.billingPortalURL.
    • Origin parity: AuthEnvironment resolves the portal URL with CMUX_BILLING_WWW_ORIGIN override (else app web origin), matching checkout; tests cover origin override and anonymous fallback. Localized strings updated (en/ja).
  • Migration

    • Enable Stripe Billing Portal and set STRIPE_SECRET_KEY.
    • Optionally set CMUX_BILLING_WWW_ORIGIN if the billing host differs; otherwise the app’s web origin is used.

Written for commit cb8d102. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added “Manage billing” for active Pro users, with a billing portal link on pricing and the billing success screen.
    • Pricing now reflects the user’s current plan, showing a current-plan state for active Pro accounts and updating CTAs accordingly.
    • macOS settings now detects active Pro status and refreshes billing state automatically.
    • Added new English/Japanese UI copy for current-plan and manage-billing labels.
  • Bug Fixes
    • Billing links now route to the correct destination (portal vs Pro upgrade) based on active subscription status.

GET /api/billing/portal resolves the current Stack user with the same
cookie semantics as checkout (including the anonymous purchaser
fallback), looks up their Stripe customer strictly by stackUserId, and
302s to a Stripe billing portal session; every failure path redirects
safely and captures to Sentry. Manage billing links appear for Pro
users on /billing/success, /app-pricing, the localized pricing page,
and the native Settings Account card, all through the shared presenter
and the same billing-origin resolution as checkout. Localized en + ja.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jul 6, 2026 8:16am
cmux-staging Building Building Preview, Comment Jul 6, 2026 8:16am

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds Pro entitlement and billing-portal handling to the macOS account flow and to the web pricing and billing pages, with new portal routing, localized billing copy, and tests for the updated flows.

Changes

macOS Account and billing flow

Layer / File(s) Summary
AccountFlow and portal URL entrypoints
Packages/macOS/CmuxSettingsUI/.../AccountFlow.swift, Sources/Auth/AuthEnvironment.swift, Sources/PricingPlansScreen.swift, cmuxTests/AuthEnvironmentTests.swift
AccountFlow gains billing-plan refresh, billing portal access, and Pro entitlement state; billing portal URL resolution and the portal presenter entrypoint are added, with a test covering URL resolution.
HostAccountFlow billing state
Sources/Auth/HostAccountFlow.swift
isProActive defaults to false and resets on sign-out; refreshBillingPlan() fetches and decodes billing status, and openBillingPortal() routes through the presenter.
ProUpgradeCard conditional Pro UI
Packages/macOS/CmuxSettingsUI/.../ProUpgradeCard.swift, Resources/Localizable.xcstrings
The Pro card uses dynamic subtitle and button text, branches its action on isProActive, refreshes billing state on identity changes, and adds localized active-subscription copy.

Web billing portal and pricing flow

Layer / File(s) Summary
Billing portal API route
web/app/api/billing/portal/route.ts, skills/cmux-billing/SKILL.md
Adds GET /api/billing/portal to resolve the Stack user, look up the Stripe customer, create a portal session, redirect on success or failure, and record configuration errors, with matching billing documentation.
Localized pricing page Pro rendering
web/app/[locale]/pricing/page.tsx
Converts pricing to an async server component that computes current Pro status and switches the Pro card and compare-table action between current-plan and upgrade states.
App pricing labels and billing link
web/app/app-pricing/page.tsx
Replaces hardcoded current-plan text with localized keys and adds a billing portal link for active Pro users.
Billing success manage-billing action
web/app/billing/success/page.tsx
Adds a manage-billing label and replaces the success-page account-settings link with a billing portal link.
Pricing and billing success strings
web/messages/en.json, web/messages/ja.json
Adds currentPlan and manageBilling translation keys for pricing and billing success text in English and Japanese.
Billing route and pricing page tests
web/tests/billing-portal-route.test.ts, web/tests/app-pricing-page.test.tsx, web/tests/pricing-page.test.tsx, web/tests/billing-success-page.test.tsx
Adds and updates tests for portal redirects, Pro-aware pricing rendering, app pricing billing links, and the billing success page.

Estimated code review effort: 3 (Moderate) | ~35 minutes


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (4 errors)

Check name Status Explanation Resolution
Cmux Swift @Concurrent ❌ Error HostAccountFlow.refreshBillingPlan() is @MainActor async yet does URLSession/decoding, and ProUpgradeCard calls it directly from .task without an off-main hop. Move the fetch/JSON decode into a nonisolated @concurrent helper, then hop back to MainActor only to set isProActive.
Cmux User-Facing Error Privacy ❌ Error ProUpgradeCard shows a visible subtitle: “Manage billing or cancel in Stripe.” That leaks an upstream vendor name in user-facing copy. Replace the subtitle with generic wording like “Manage billing or cancel your subscription,” and keep Stripe mentions out of visible text.
Cmux Full Internationalization ❌ Error Localized pricing/billing pages add new keys only in en/ja, but routing.ts supports 20 locales; 18 message catalogs lack pricing.currentPlan/manageBilling and billingSuccess.manageBilling. Add translated pricing.currentPlan, pricing.manageBilling, and billingSuccess.manageBilling entries to every web/messages/.json for all locales in web/i18n/routing.ts.
Cmux No Ambient Global State ❌ Error FAIL: Sources/PricingPlansScreen.swift:33 adds ProUpgradePresenter.presentBillingPortal() on a static helper namespace, adding ambient global API instead of an injected owner. Move billing-portal opening onto an instance-owned router/flow (e.g. HostAccountFlow) and keep any URL helper private/file-private.
✅ Passed checks (21 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed The new billing APIs live on existing @MainActor UI/coordinator types; no new off-main Sendable state or background access was introduced.
Cmux Swift Blocking Runtime ✅ Passed Touched Swift files only add async network calls and URL openings; no new semaphores, sync waits, sleeps, asyncAfter, polling, or manual locks were introduced.
Cmux Browser Automation Off-Main ✅ Passed The diff only adds __internal_flags UI-opening logic in TerminalController; no browser.* routing, WebKit/AppKit wait changes, or policy-test gaps were introduced.
Cmux Expensive Synchronous Load ✅ Passed Touched Swift files only add async billing-plan refreshes and URL opens; no main-actor sync agent-history/session/transcript loads were added or moved.
Cmux Cache Substitution Correctness ✅ Passed The new billing-plan and portal flows use live API/Stack reads on demand; any cached values are transient UI state and are freshness-checked via task/id refresh.
Cmux No Hacky Sleeps ✅ Passed No touched TS/JS runtime files add sleeps, timers, polling, or backoff; the new billing routes/pages are deterministic and tests use mocks only.
Cmux Algorithmic Complexity ✅ Passed New work is bounded: DB lookups use indexed queries, Pro-status pagination is capped at 10×50 items, and UI loops only traverse tiny static locale/feature lists.
Cmux Swift Concurrency ✅ Passed The Swift changes use async/await, SwiftUI .task, and AppKit/URLSession boundaries; I found no newly introduced DispatchQueue, completion-handler, or lifecycle Task anti-patterns.
Cmux Swift File And Package Boundaries ✅ Passed The PR only adds small glue to existing files; no new oversized Swift file or large expansion, and the account-flow package boundary remains intact.
Cmux Swiftpm Lockfiles ✅ Passed cmux.xcodeproj/project.pbxproj only adds source-file refs; no SwiftPM package refs, Package.resolved, or .gitignore lockfile changes appear in the diff.
Cmux Swift Logging ✅ Passed Changed production Swift files add no print/debugPrint/dump/NSLog calls or file-scoped Logger constants; only URL/navigation and state logic changed.
Cmux Swiftui State Layout ✅ Passed No prohibited SwiftUI state-layout pattern was introduced: the PR adds protocol/model methods, and the only ObservableObject/@StateObject usage is existing AppKit-bridge pricing UI state.
Cmux Architecture Rethink ✅ Passed The PR adds a single host-owned billing-state bridge and browser portal opener; no sleeps, polling, duplicate owners, or split lifecycle workaround appeared.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No new or changed standalone cmux window code appears in the diff; the Swift changes only add portal-opening/state-refresh logic, and the existing pricing window/identifier wiring is untouched.
Cmux Source Artifacts ✅ Passed Changed paths are intentional source, tests, docs, localization, and repo config; the .github TSV is a checked-in CI budget reference, not generated output.
Cmux No Test Or Debug Seam In Production Source ✅ Passed The production diff only adds billing-portal feature APIs/UI; no new debug*/ForTesting members or test-only #if DEBUG seams were introduced.
Title check ✅ Passed It clearly summarizes the primary change: adding a Stripe customer portal for Pro billing management.
Description check ✅ Passed It covers the required summary and testing intent, but omits the demo video, review trigger block, and checklist details.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-billing-portal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread web/app/api/billing/portal/route.ts
PricingPlansScreen.swift grew 5 lines for the Manage billing action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1eaefdc2af

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +117 to +121
{snapshot.isPro ? (
<div className="space-y-2">
<DisabledButton>{t("currentPlan")}</DisabledButton>
<SecondaryLink href="/api/billing/portal">
{t("manageBilling")}

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 👍 / 👎.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c738561. Configure here.


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.

@greptile-apps

greptile-apps Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a self-serve Stripe billing management flow for Pro subscribers — a new GET /api/billing/portal route that resolves the current Stack user (with anonymous purchaser fallback), looks up their stripe_customers row, creates a Billing Portal session, and 302-redirects, with safe /pricing?billing=… error fallbacks. "Manage billing" links are surfaced across the web pricing pages, billing success page, and the macOS Settings Account card via ProUpgradeCard/HostAccountFlow.

  • New API route (web/app/api/billing/portal/route.ts): resolves signed-in and anonymous Stack users, enforces a stripe_customers row gate, creates a Billing Portal session, and captures errors with Sentry including a stripePortalConfigurationMissing hint.
  • Web pages ([locale]/pricing, app-pricing, billing/success): converted to server-rendered with force-dynamic; resolveProPlanStatus drives the "Current plan" badge and portal link for Pro users; en+ja i18n keys added.
  • macOS (HostAccountFlow, ProUpgradeCard, AuthEnvironment): adds refreshBillingPlan(), isProActive, and openBillingPortal(); the Settings card auto-refreshes plan state on identity change and switches Upgrade vs Manage billing; portal URL follows the CMUX_BILLING_WWW_ORIGIN override pattern used by checkout.

Confidence Score: 5/5

Safe to merge; the portal route, plan refresh, and error fallbacks are all well-tested and follow the established checkout pattern.

The portal route correctly gates on both Stack and Stripe configuration, enforces the stripe_customers row before creating a session, and captures errors without exposing internals. The one subtle logic issue is in refreshBillingPlan(): if currentTokens() throws, the request is sent unauthenticated and isProActive is silently reset to false, causing a Pro subscriber to briefly see "Upgrade…" in Settings — but this is an edge case limited to transient token-refresh failures and self-heals on next open. All critical paths (user resolution, session creation, error handling, origin resolution) have matching tests.

Sources/Auth/HostAccountFlow.swift — the try?-then-continue pattern in refreshBillingPlan() can produce incorrect plan state during token refresh failures.

Important Files Changed

Filename Overview
web/app/api/billing/portal/route.ts New GET handler that resolves Stack user → stripe_customers → Stripe portal session → 302 redirect; error paths redirect to /pricing with Sentry capture. Logic is sound, tests cover all branches.
Sources/Auth/HostAccountFlow.swift Adds refreshBillingPlan() async, openBillingPortal(), and isProActive. Network request is unauthenticated if currentTokens() throws (silently falls back to isProActive = false).
Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift Adds .task(id:) refresh, branching subtitleText/buttonTitle, and portal vs upgrade dispatch. Statically correct; relies on HostAccountFlow being @observable for re-render after refresh.
Resources/Localizable.xcstrings Adds settings.account.pro.activeSubtitle and settings.account.pro.manageBilling with only en+ja translations, matching the existing pattern for all settings.account.pro.* keys.
web/app/[locale]/pricing/page.tsx Converted from client to async server component with force-dynamic; currentPlanSnapshot() resolves Pro status for badge/portal link. force-dynamic trades static generation for personalization.
web/tests/billing-portal-route.test.ts Comprehensive route tests covering signed-in user, anonymous fallback, no-user, unconfigured Stripe, missing customer row, session creation failure, and portal-config error tagging.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant U as User
    participant W as Web (/api/billing/portal)
    participant S as Stack Auth
    participant DB as stripe_customers DB
    participant SP as Stripe Portal API

    U->>W: GET /api/billing/portal
    W->>W: "isStackConfigured() && isStripeBillingConfigured()"
    alt not configured
        W-->>U: "302 /pricing?billing=unavailable"
    end

    W->>S: "getUser({ or: "return-null" })"
    alt no signed-in user
        W->>S: "getUser({ or: "anonymous-if-exists[deprecated]" })"
        alt no anonymous purchaser either
            W-->>U: 302 /pricing
        end
    end

    W->>DB: "SELECT id FROM stripe_customers WHERE stackUserId = user.id LIMIT 1"
    alt no customer row
        W-->>U: "302 /pricing?billing=unavailable"
    end

    W->>SP: "billingPortal.sessions.create({ customer, return_url: /pricing })"
    alt Stripe error
        W->>W: captureBillingError (+ portal config flag)
        W-->>U: "302 /pricing?billing=error"
    end

    W-->>U: 302 → session.url (Stripe hosted portal)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant U as User
    participant W as Web (/api/billing/portal)
    participant S as Stack Auth
    participant DB as stripe_customers DB
    participant SP as Stripe Portal API

    U->>W: GET /api/billing/portal
    W->>W: "isStackConfigured() && isStripeBillingConfigured()"
    alt not configured
        W-->>U: "302 /pricing?billing=unavailable"
    end

    W->>S: "getUser({ or: "return-null" })"
    alt no signed-in user
        W->>S: "getUser({ or: "anonymous-if-exists[deprecated]" })"
        alt no anonymous purchaser either
            W-->>U: 302 /pricing
        end
    end

    W->>DB: "SELECT id FROM stripe_customers WHERE stackUserId = user.id LIMIT 1"
    alt no customer row
        W-->>U: "302 /pricing?billing=unavailable"
    end

    W->>SP: "billingPortal.sessions.create({ customer, return_url: /pricing })"
    alt Stripe error
        W->>W: captureBillingError (+ portal config flag)
        W-->>U: "302 /pricing?billing=error"
    end

    W-->>U: 302 → session.url (Stripe hosted portal)
Loading

Reviews (2): Last reviewed commit: "Merge main and regenerate the Swift leng..." | Re-trigger Greptile

Comment thread web/messages/en.json
Comment on lines 240 to 242
"title": "Pricing",
"perMonth": "/month",
"perUserMonth": "/user/month",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Missing translations in 18 of 20 locale files

pricing.currentPlan, pricing.manageBilling, and billingSuccess.manageBilling are added only to en.json and ja.json, but web/messages/ contains 20 locales (ar, bs, da, de, es, fr, it, km, ko, no, pl, pt-BR, ru, th, tr, uk, zh-CN, zh-TW are untouched). The localized pricing page at web/app/[locale]/pricing/page.tsx calls t("currentPlan") and t("manageBilling") via getTranslations, so non-en/ja visitors see the raw key name instead of translated text. Worse, billingSuccessMessages() in web/app/billing/success/page.tsx dynamically imports the locale file and casts billingSuccess directly to BillingSuccessMessages—the new manageBilling field will be undefined at runtime for any locale other than en/ja, so the "Manage billing" button renders with no text for the majority of supported languages.

Rule Used: Flag production user-facing text that is not fully... (source)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with 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.

Inline comments:
In `@Sources/Auth/HostAccountFlow.swift`:
- Around line 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.

In `@web/app/`[locale]/pricing/page.tsx:
- Around line 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.
- 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.

In `@web/app/api/billing/portal/route.ts`:
- Line 16: The ANONYMOUS_IF_EXISTS sentinel is duplicated across the billing
portal route and the pricing page, so move the shared literal into a single
exported constant in web/app/lib/stack.ts and import it in both call sites.
Update the uses in the route handler and the pricing page to reference that
shared symbol instead of redefining the string inline, keeping the existing
sentinel value consistent in one place.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c83c8b73-0a4e-47a0-b849-58257f70f722

📥 Commits

Reviewing files that changed from the base of the PR and between 763185a and c738561.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (18)
  • Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift
  • Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift
  • Resources/Localizable.xcstrings
  • Sources/Auth/AuthEnvironment.swift
  • Sources/Auth/HostAccountFlow.swift
  • Sources/PricingPlansScreen.swift
  • cmuxTests/AuthEnvironmentTests.swift
  • skills/cmux-billing/SKILL.md
  • web/app/[locale]/pricing/page.tsx
  • web/app/api/billing/portal/route.ts
  • web/app/app-pricing/page.tsx
  • web/app/billing/success/page.tsx
  • web/messages/en.json
  • web/messages/ja.json
  • web/tests/app-pricing-page.test.tsx
  • web/tests/billing-portal-route.test.ts
  • web/tests/billing-success-page.test.tsx
  • web/tests/pricing-page.test.tsx

Comment on lines +91 to +117
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
}
}

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.

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.

Comment on lines +272 to +285

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.

📐 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

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const ANONYMOUS_IF_EXISTS = "anonymous-if-exists[deprecated]" as const;

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 | 🔵 Trivial | ⚡ Quick win

Centralize the ANONYMOUS_IF_EXISTS sentinel instead of duplicating it.

This exact literal ("anonymous-if-exists[deprecated]") is redefined verbatim in web/app/[locale]/pricing/page.tsx. Per official Stack Auth docs, the publicly documented getUser({or}) options are only "return-null" | "redirect" | "throw" — this sentinel appears to be an internal/forked extension, which makes copy-pasting it across files riskier if the SDK ever changes the value.

getUser(options?: { or?: "return-null" | "redirect" | "throw" }): Promise<CurrentServerUser | null>

Export this as a single shared constant from web/app/lib/stack.ts and import it in both call sites.

♻️ Proposed fix
-const ANONYMOUS_IF_EXISTS = "anonymous-if-exists[deprecated]" as const;
+import { ANONYMOUS_IF_EXISTS } from "../../../lib/stack";

Also applies to: 54-60

🤖 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/api/billing/portal/route.ts` at line 16, The ANONYMOUS_IF_EXISTS
sentinel is duplicated across the billing portal route and the pricing page, so
move the shared literal into a single exported constant in web/app/lib/stack.ts
and import it in both call sites. Update the uses in the route handler and the
pricing page to reference that shared symbol instead of redefining the string
inline, keeping the existing sentinel value consistent in one place.

Both this branch and the merged flags PR refreshed the budget file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cb8d10210a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


@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 👍 / 👎.

This branch was successfully deployed

1 active deployment
Preview – cmux — cb8d1021 Deployed Jul 6, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant