Skip to content

Gate billing management by kind and polish the pricing pages - #7479

Merged
lawrencecchen merged 1 commit into
mainfrom
feat-billing-polish
Jul 7, 2026
Merged

lawrencecchen merged 1 commit into
mainfrom
feat-billing-polish

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 7, 2026 •

Copy link
Copy Markdown
Contributor

Reproduced live: legacy Stack-Pro users (Pro without a stripe_customers row, i.e. every pre-Stripe subscriber) clicked Manage billing and dead-ended at "Billing is not available right now." The plan status now classifies billingManagement (stripe, external, none) in one place; all Manage billing surfaces gate on stripe; legacy users see a localized managed-elsewhere note; the portal route redirects them to the distinct billing=external banner and keeps unavailable strictly for Stripe-unconfigured, with Sentry capture only for genuinely anomalous missing customer rows. Also: the free plan feature list is no longer dimmed, and the pricing compare/size tables get mobile-only overflow wrappers so the page has zero horizontal scroll at 320-1920 while the desktop sticky compare header still pins to the page (the naive full-width wrapper that breaks sticky was caught in review and avoided).

/fable loop: two judge rounds; live re-verification as the affected legacy-Pro account (portal -> billing=external, no dangling links), a Stripe-managed account (portal link intact), sticky pinning at 1280 and overflow-free 320-767 in a real browser; 418 web tests green in sorted order.

🤖 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 classification, portal redirects, and customer-facing subscription management across web and macOS; behavior is covered by expanded route/page tests but misclassification could hide portal access or show wrong messaging.

Overview
Introduces billingManagement (stripe | external | none) from resolveProPlanStatus and threads it through /api/billing/plan, web pricing/app-pricing, banners, and macOS Settings so Manage billing and the Stripe portal only appear when billing is Stripe-managed.

Legacy / Stack-only Pro users no longer hit a dead-end “billing unavailable” flow: they see a localized managed elsewhere message, the portal route redirects with billing=external, and billing=unavailable stays for Stripe-not-configured. Anomalous Stripe-managed users missing a stripe_customers row trigger Sentry and the same external redirect.

macOS adds canManageBilling on AccountFlow; the Pro row hides the action button for external Pro and shows the external subtitle.

Pricing UI polish: free-tier feature lists are no longer dimmed; compare/size tables use mobile-only horizontal scroll so desktop sticky compare headers still pin to the page.

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


Summary by cubic

Gate “Manage billing” to Stripe-managed subscriptions and show a clear “managed elsewhere” note for legacy Pro users. Also polish pricing pages to remove horizontal scrolling on mobile and keep the sticky compare header working on desktop.

  • New Features

    • Classified plan state with billingManagement = stripe | external | none and exposed it via the plan API.
    • macOS: added AccountFlow.canManageBilling and updated the Pro card to show the portal button only when Stripe-managed.
    • Web pricing pages and banners now show the portal link only for stripe; legacy users see a localized “managed elsewhere” message.
    • UI: free plan features are no longer dimmed; compare/size tables use mobile-only overflow wrappers so the page doesn’t scroll horizontally while the desktop header stays sticky.
  • Bug Fixes

    • Fixed legacy Stack Pro users dead-ending on “Billing is not available.” The portal route redirects them to pricing?billing=external; unavailable is reserved for Stripe-unconfigured.
    • Capture an error only when a Stripe-managed user is missing a Stripe customer row; otherwise show the external billing banner.

Written for commit 53d6d3a. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added clearer billing-management states across pricing and account screens, including support for “Manage billing” vs. external-managed subscriptions.
    • Introduced new localized messages for external billing and updated pricing flow messaging.
  • Bug Fixes

    • Improved billing portal and pricing redirects for non-Stripe-managed subscriptions.
    • Updated account and dashboard copy to better explain when billing changes must be handled through support.
  • Tests

    • Expanded billing and pricing test coverage for Stripe-managed, external-managed, and free-user scenarios.

resolveProPlanStatus now classifies billingManagement as stripe,
external, or none; the plan API exposes it and every Manage billing
surface (web pricing pages and the native Settings card) renders only
for Stripe-managed subscriptions, with legacy Stack Pro users seeing a
localized managed-elsewhere note instead of a dead link. The portal
route sends no-customer users to the distinct billing=external banner
and reserves unavailable for Stripe-unconfigured, capturing to Sentry
only when a Stripe-managed customer row is genuinely missing. The free
plan feature list renders undimmed, and the pricing compare and size
tables scroll inside mobile-only overflow wrappers so the page never
scrolls horizontally at 320-1920 while the desktop sticky compare
header keeps pinning to the page.

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

vercel Bot commented Jul 7, 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 7, 2026 12:52am
cmux-staging Building Building Preview, Comment Jul 7, 2026 12:52am

@coderabbitai

coderabbitai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces a billingManagement classification (stripe / external / none) distinguishing Stripe-managed from externally-managed Pro subscriptions. Propagates this through macOS AccountFlow/HostAccountFlow, web billing service resolution, pricing pages, billing API routes, localized copy, and updates FeatureList/pricing table layout styling, with corresponding test updates.

Changes

Billing Management Differentiation

Layer / File(s) Summary
macOS AccountFlow contract and ProUpgradeCard UI
Packages/macOS/CmuxSettingsUI/.../AccountFlow.swift, Packages/macOS/CmuxSettingsUI/.../ProUpgradeCard.swift, Resources/Localizable.xcstrings
AccountFlow gains canManageBilling; ProUpgradeCard branches its button/subtitle on it instead of isProActive, with a new localized external-subtitle string.
HostAccountFlow billing state and decoding
Sources/Auth/HostAccountFlow.swift
Adds canManageBilling state, cleared on sign-out/failure, derived from a new billingManagement field (BillingManagement enum) in BillingPlanResponse.
Web billing service resolution
web/services/billing/pro.ts, web/tests/billing-pro.test.ts
ProPlanStatus gains billingManagement/BillingManagementKind; resolveProPlanStatus uses a new activeProSubscriptionState helper to distinguish Stripe vs other active Pro sources.
Billing plan and portal API routes
web/app/api/billing/plan/route.ts, web/app/api/billing/portal/route.ts, web/tests/billing-plan-route.test.ts, web/tests/billing-portal-route.test.ts
Plan route returns billingManagement in all responses; portal route redirects to external billing with error capture when a Stripe user lacks a customer row.
Pricing pages and welcome banners
web/app/[locale]/pricing/page.tsx, web/app/app-pricing/page.tsx, web/app/[locale]/components/pro-welcome-banner.tsx, web/tests/pricing-page.test.tsx, web/tests/app-pricing-page.test.tsx
Pricing pages branch “manage billing” UI between Stripe portal links and external-billing messages based on billingManagement; snapshots and banners updated accordingly.
Pricing shared UI components
web/app/components/pricing-shared.tsx
FeatureList drops muted prop; PlanCard gains min-w-0; compare/size tables switch to mobile-only horizontal scroll with minimum widths.
Localized billing copy
web/messages/en.json, web/messages/ja.json, web/tests/dashboard-billing-page.test.tsx
Updates legacy billing body text and adds billingExternal keys in English and Japanese.

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

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant PricingPage
  participant PlanRoute as "/api/billing/plan"
  participant ProService as "resolveProPlanStatus"
  participant PortalRoute as "/api/billing/portal"

  User->>PricingPage: View pricing page
  PricingPage->>ProService: currentPlanSnapshot()
  ProService->>ProService: activeProSubscriptionState()
  ProService-->>PricingPage: isPro, billingManagement
  alt billingManagement == "stripe"
    PricingPage->>User: Show "Manage billing" (portal link)
    User->>PortalRoute: Click portal link
    PortalRoute->>ProService: resolveProPlanStatus(user)
    ProService-->>PortalRoute: billingManagement == "stripe"
    alt missing Stripe customer row
      PortalRoute->>PortalRoute: captureBillingError()
      PortalRoute-->>User: Redirect billing=external
    else customer row exists
      PortalRoute-->>User: Redirect to Stripe portal
    end
  else billingManagement == "external"
    PricingPage->>User: Show external billing note
  end
Loading

Possibly related PRs

  • manaflow-ai/cmux#6791: Touches the same pricing page later updated to branch “manage billing” UI on billingManagement.
  • manaflow-ai/cmux#7143: Builds on the same Pro billing/pricing/CTA flow extended here with billingManagement and portal routing.
  • manaflow-ai/cmux#7443: Introduces the billing portal route and openBillingPortal() flow that canManageBilling now gates.
🚥 Pre-merge checks | ✅ 4 | ❌ 21

❌ Failed checks (1 warning, 20 inconclusive)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers summary and testing, but it misses required template sections like Demo Video, Review Trigger, and Checklist. Reformat the PR description to the template and add Demo Video, Review Trigger, and Checklist sections with the required details.
Cmux Swift Actor Isolation ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swift Blocking Runtime ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Browser Automation Off-Main ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Expensive Synchronous Load ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Cache Substitution Correctness ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux No Hacky Sleeps ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Algorithmic Complexity ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swift Concurrency ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swift @Concurrent ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swift File And Package Boundaries ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swiftpm Lockfiles ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swift Logging ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux User-Facing Error Privacy ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Full Internationalization ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swiftui State Layout ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Architecture Rethink ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Swift Auxiliary Window Close Shortcuts ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux Source Artifacts ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux No Test Or Debug Seam In Production Source ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Cmux No Ambient Global State ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main change: gating billing management by billing kind and polishing pricing pages.
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.
✨ 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-polish

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.

@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 53d6d3a. Configure here.

},
);
}
return pricingRedirect(request, "external");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Wrong portal redirect without customer

Medium Severity

When no stripe_customers row exists, the portal handler always redirects to ?billing=external, regardless of resolveProPlanStatus’s billingManagement. Free accounts (none) and Stripe subscription anomalies (stripe with a missing customer row) see the legacy “previous billing system” banner instead of a neutral or error state.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 53d6d3a. Configure here.

@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR gates billing management by the new billing-management kind. The main changes are:

  • Adds billingManagement to Pro plan status and API responses.
  • Shows Manage Billing only for Stripe-managed subscriptions.
  • Adds external-billing messaging for legacy Pro users.
  • Adjusts pricing page feature styling and mobile table overflow.

Confidence Score: 4/5

The changed billing flow needs fixes before merging. Legacy Stack Pro classification can still fail when the Stripe DB lookup fails, and the new external-billing copy is missing for most web locales.

  • Stack-managed Pro users now depend on a Stripe subscription lookup even after Stack already proves they are active.
  • The new billingExternal state is reachable from pricing pages and portal redirects, but most locale files do not define that key.
  • The Swift Settings changes and pricing layout changes did not show a concrete blocking issue in the inspected changed code.

web/services/billing/pro.ts, web/app/[locale]/pricing/page.tsx, web/app/[locale]/components/pro-welcome-banner.tsx, web/messages/*.json

Important Files Changed

Filename Overview
web/services/billing/pro.ts Adds billing-management classification, but Stack-active users now still depend on a Stripe DB lookup.
web/app/api/billing/portal/route.ts Redirects missing-customer billing cases to the external banner and records anomalous Stripe-managed cases.
web/app/api/billing/plan/route.ts Returns the new billingManagement field from resolved Pro status.
web/app/[locale]/pricing/page.tsx Uses billing-management state to hide or show the portal link, but references a key missing from most locales.
web/app/[locale]/components/pro-welcome-banner.tsx Adds the billing=external banner state, but depends on incomplete locale coverage.
web/app/components/pricing-shared.tsx Updates pricing card width behavior, feature-list styling, and mobile overflow wrappers.
Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift Adds canManageBilling to the account flow contract.
Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift Uses canManageBilling to choose the Pro billing action and external-management subtitle.
Resources/Localizable.xcstrings Adds the new macOS external-billing subtitle in English and Japanese.

Reviews (1): Last reviewed commit: "Gate billing management by kind and poli..." | Re-trigger Greptile

: false;
if (stackActive) return true;
return user.id ? hasActiveStripeSubscription(user.id) : false;
const stripeActive = user.id ? await hasActiveStripeSubscription(user.id) : false;

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 Stack Pro Depends On Stripe DB

When a legacy Stack Pro user reaches the plan or portal routes, this helper now still queries Stripe after stackActive is true. A non-missing-config DB error from that Stripe lookup is rethrown, so the same legacy users this change is meant to classify as external can get a failed plan response or a billing=error redirect instead of the managed-elsewhere state.

</SecondaryLink>
) : (
<p className="text-sm leading-6 text-muted">
{t("billingExternal")}

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 External Billing Key Missing

This localized pricing page now renders t("billingExternal") for externally managed Pro users, but the new key was only added to the English and Japanese message files. In any other supported locale, a Pro user with billingManagement !== "stripe" can hit a missing next-intl message instead of the managed-elsewhere note.

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

? t("billingInvalidPlan")
: null;
: billing === "external"
? t("billingExternal")

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 Redirect Banner Lacks Locales

The portal route can now redirect users to /pricing?billing=external, and this banner renders that state through t("billingExternal"). Because the key is absent from most supported web/messages locale files, non-English/Japanese users can see a missing translation or runtime message error instead of the billing guidance.

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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
web/services/billing/pro.ts (1)

217-235: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Run the two independent subscription lookups concurrently.

stackActive and stripeActive are independent I/O calls (Stack API listProducts + a DB query) but are awaited sequentially. Since resolveProPlanStatus runs on hot paths (pricing page SSR via currentPlanSnapshot, the /api/billing/plan route hit on every load, and the portal redirect), running them with Promise.all would cut the round-trip latency roughly in half for pro-status resolution.

⚡ Proposed fix to parallelize the lookups
 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 };
+  const [stackActive, stripeActive] = await Promise.all([
+    typeof user.listProducts === "function"
+      ? hasActiveProSubscription(user)
+      : Promise.resolve(false),
+    user.id ? hasActiveStripeSubscription(user.id) : Promise.resolve(false),
+  ]);
+  return { stackActive, stripeActive };
 }
🤖 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/services/billing/pro.ts` around lines 217 - 235, The pro-subscription
checks in activeProSubscriptionState are awaited sequentially even though the
Stack and Stripe lookups are independent. Update activeProSubscriptionState to
start both calls together with Promise.all, using the existing
hasActiveStripeSubscription and the stack lookup from
hasAnyActiveProSubscription/user.listProducts, so stackActive and stripeActive
resolve in parallel. Keep hasAnyActiveProSubscription as the caller that
combines the two booleans, and preserve the existing fallback behavior when
user.listProducts or user.id is missing.
web/app/[locale]/components/pro-welcome-banner.tsx (1)

24-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Nested ternary chain keeps growing.

The message derivation is now an 8-branch nested ternary spanning welcome and billing params. Adding the new "external" branch is correct, but each future banner state will make this harder to read/maintain safely. Consider a lookup table keyed by welcome/billing value instead.

♻️ Suggested refactor
-  const message =
-    welcome === "success"
-      ? t("welcomeSuccess")
-      : welcome === "active"
-        ? t("welcomeActive")
-        : welcome === "pending"
-          ? t("welcomePending")
-          : welcome === "team"
-            ? t("welcomeTeam")
-            : billing === "error"
-              ? t("billingError")
-              : billing === "unavailable"
-                ? t("billingUnavailable")
-                : billing === "external"
-                  ? t("billingExternal")
-                  : billing === "cancelled"
-                    ? t("billingCancelled")
-                    : billing === "invalid_plan"
-                      ? t("billingInvalidPlan")
-                      : null;
+  const welcomeKey = welcome
+    ? ({ success: "welcomeSuccess", active: "welcomeActive", pending: "welcomePending", team: "welcomeTeam" } as const)[welcome]
+    : undefined;
+  const billingKey = billing
+    ? ({ error: "billingError", unavailable: "billingUnavailable", external: "billingExternal", cancelled: "billingCancelled", invalid_plan: "billingInvalidPlan" } as const)[billing]
+    : undefined;
+  const message = welcomeKey ? t(welcomeKey) : billingKey ? t(billingKey) : null;
🤖 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]/components/pro-welcome-banner.tsx around lines 24 - 34, The
message selection logic in ProWelcomeBanner is becoming too hard to maintain
because the nested ternary now branches across both welcome and billing states.
Refactor the message derivation in pro-welcome-banner.tsx to use a lookup table
or mapping keyed by the state value instead of chaining conditions, and keep the
existing keys like billingExternal, billingCancelled, and billingInvalidPlan
reachable through that mapping.
web/app/app-pricing/page.tsx (1)

236-274: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse the shared BillingManagementKind type instead of re-declaring the union.

AppPlanSnapshot.billingManagement re-declares "stripe" | "external" | "none" inline, duplicating the type already introduced in web/services/billing/pro.ts (BillingManagementKind). The same literal is also duplicated in the locale pricing page. If a fourth state is ever added, all call sites must be updated in lockstep or they'll silently drift.

♻️ Suggested fix
-import { FREE_PLAN_ID, resolveProPlanStatus } from "../../services/billing/pro";
+import {
+  FREE_PLAN_ID,
+  resolveProPlanStatus,
+  type BillingManagementKind,
+} from "../../services/billing/pro";
...
 type AppPlanSnapshot = {
   authenticated: boolean;
   planId: string;
   isPro: boolean;
-  billingManagement: "stripe" | "external" | "none";
+  billingManagement: BillingManagementKind;
   email: string | null;
 };
🤖 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/app-pricing/page.tsx` around lines 236 - 274, Reuse the shared
BillingManagementKind type in AppPlanSnapshot instead of re-declaring the inline
"stripe" | "external" | "none" union. Update currentPlanSnapshot and any related
billingManagement typings in this pricing page to import and use
BillingManagementKind from web/services/billing/pro.ts so the type stays aligned
with the shared source of truth. Keep the snapshot shape and return values
unchanged, only replace the duplicated local union with the shared type.
🤖 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
`@Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift`:
- Around line 75-78: `AccountFlow` currently collapses billing management into a
Bool, which hides the difference between legacy/external billing and the
missing-customer anomaly. Update the `AccountFlow` contract and
`HostAccountFlow` mapping to preserve the full billing state (or add an explicit
unavailable case) instead of only exposing `canManageBilling`, then adjust
`ProUpgradeCard` to branch on that richer state so the anomaly can be handled
separately from `external` billing.

In `@web/app/`[locale]/pricing/page.tsx:
- Around line 278-293: The currentPlanSnapshot logic is duplicated in multiple
pricing page modules, including the current pricing page and the app-pricing
page, which risks the Pro/billing contract drifting over time. Extract
currentPlanSnapshot into a shared helper used by both pages, keeping the
existing isStackConfigured, getStackServerApp().getUser, and
resolveProPlanStatus flow in one place so billingManagement stays consistent
everywhere.

In `@web/app/api/billing/portal/route.ts`:
- Around line 32-46: The portal route currently treats all non-Stripe cases the
same, so users with billingManagement "none" are redirected as if they had
external billing. Update the logic in the billing portal handler to distinguish
the "none" case from "external" using resolveProPlanStatus and pricingRedirect,
and only use the external billing redirect for actual external/managed billing
users. Keep the existing captureBillingError path in the stripe-managed
missing-customer branch, and make sure the redirect target reflects the user’s
real billing state.

---

Outside diff comments:
In `@web/app/`[locale]/components/pro-welcome-banner.tsx:
- Around line 24-34: The message selection logic in ProWelcomeBanner is becoming
too hard to maintain because the nested ternary now branches across both welcome
and billing states. Refactor the message derivation in pro-welcome-banner.tsx to
use a lookup table or mapping keyed by the state value instead of chaining
conditions, and keep the existing keys like billingExternal, billingCancelled,
and billingInvalidPlan reachable through that mapping.

In `@web/app/app-pricing/page.tsx`:
- Around line 236-274: Reuse the shared BillingManagementKind type in
AppPlanSnapshot instead of re-declaring the inline "stripe" | "external" |
"none" union. Update currentPlanSnapshot and any related billingManagement
typings in this pricing page to import and use BillingManagementKind from
web/services/billing/pro.ts so the type stays aligned with the shared source of
truth. Keep the snapshot shape and return values unchanged, only replace the
duplicated local union with the shared type.

In `@web/services/billing/pro.ts`:
- Around line 217-235: The pro-subscription checks in activeProSubscriptionState
are awaited sequentially even though the Stack and Stripe lookups are
independent. Update activeProSubscriptionState to start both calls together with
Promise.all, using the existing hasActiveStripeSubscription and the stack lookup
from hasAnyActiveProSubscription/user.listProducts, so stackActive and
stripeActive resolve in parallel. Keep hasAnyActiveProSubscription as the caller
that combines the two booleans, and preserve the existing fallback behavior when
user.listProducts or user.id is missing.
🪄 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: 32fa73e6-7724-4200-9098-28255cab2008

📥 Commits

Reviewing files that changed from the base of the PR and between 2d18424 and 53d6d3a.

📒 Files selected for processing (19)
  • Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift
  • Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift
  • Resources/Localizable.xcstrings
  • Sources/Auth/HostAccountFlow.swift
  • web/app/[locale]/components/pro-welcome-banner.tsx
  • web/app/[locale]/pricing/page.tsx
  • web/app/api/billing/plan/route.ts
  • web/app/api/billing/portal/route.ts
  • web/app/app-pricing/page.tsx
  • web/app/components/pricing-shared.tsx
  • web/messages/en.json
  • web/messages/ja.json
  • web/services/billing/pro.ts
  • web/tests/app-pricing-page.test.tsx
  • web/tests/billing-plan-route.test.ts
  • web/tests/billing-portal-route.test.ts
  • web/tests/billing-pro.test.ts
  • web/tests/dashboard-billing-page.test.tsx
  • web/tests/pricing-page.test.tsx

Comment on lines +75 to +78

/// Whether the current Pro entitlement can be managed through the hosted
/// Stripe billing portal.
var canManageBilling: Bool { get }

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 | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== AccountFlow.swift ==\n'
sed -n '1,220p' Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift

printf '\n== ProUpgradeCard.swift ==\n'
sed -n '1,220p' Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift

printf '\n== Search billingManagement / canManageBilling usages ==\n'
rg -n "billingManagement|canManageBilling|managed by our previous billing system|Stripe billing portal|billing=external|billing.*external|billing.*none" Packages/macOS web .github -g '!**/DerivedData/**' -g '!**/build/**'

Repository: manaflow-ai/cmux

Length of output: 12539


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Host AccountFlow implementations ==\n'
rg -n "protocol AccountFlow|canManageBilling|billingManagement|isProActive|openBillingPortal|refreshBillingPlan" Packages Sources -g '!**/DerivedData/**' -g '!**/build/**'

printf '\n== Relevant billing types ==\n'
sed -n '1,260p' web/services/billing/pro.ts
printf '\n== Billing portal route ==\n'
sed -n '1,180p' web/app/api/billing/portal/route.ts
printf '\n== Dashboard billing page ==\n'
sed -n '1,260p' web/app/[locale]/dashboard/billing/page.tsx

Repository: manaflow-ai/cmux

Length of output: 24017


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect host-side native implementation around the billing flow.
rg -n "canManageBilling|billingManagement|HostAccountFlow|AccountFlow" Packages/macOS Sources -g '*.swift'

# Read the most relevant host implementation slices if present.
for f in \
  Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift \
  Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift
do
  printf '\n== %s ==\n' "$f"
  sed -n '1,220p' "$f"
done

Repository: manaflow-ai/cmux

Length of output: 10553


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the concrete AccountFlow implementation that feeds the macOS settings UI.
rg -n "struct .*AccountFlow|class .*AccountFlow|extension .*AccountFlow|canManageBilling" Packages/macOS Sources -g '*.swift' | sed -n '1,220p'

Repository: manaflow-ai/cmux

Length of output: 1405


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the host-side implementation if it exists in the repo.
rg -n "decoded\.billingManagement == \.stripe|billingManagement == \.stripe|canManageBilling: Bool|refreshBillingPlan\(\)" . -g '*.swift' -g '*.ts' -g '*.tsx'

Repository: manaflow-ai/cmux

Length of output: 716


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== web/services/billing/pro.ts (relevant slice) ==\n'
sed -n '1,240p' web/services/billing/pro.ts

printf '\n== web/app/api/billing/portal/route.ts (relevant slice) ==\n'
sed -n '1,180p' web/app/api/billing/portal/route.ts

printf '\n== web/app/[locale]/dashboard/billing/page.tsx (relevant slice) ==\n'
sed -n '1,260p' web/app/[locale]/dashboard/billing/page.tsx

Repository: manaflow-ai/cmux

Length of output: 20677


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the concrete AccountFlow implementation and any billing-management plumbing.
rg -n "HostAccountFlow|canManageBilling|billingManagement|hasStripeCustomer|stripeActive|openBillingPortal" Packages Sources web -g '*.swift' -g '*.ts' -g '*.tsx'

Repository: manaflow-ai/cmux

Length of output: 5161


Thread the billing state through AccountFlow
HostAccountFlow maps decoded.billingManagement == .stripe into canManageBilling, so external and the missing-customer anomaly both become false. ProUpgradeCard then shows the same legacy-billing copy and hides the action for both cases. Carry the tri-state through here, or add an explicit unavailable case, so the anomaly can be handled separately.

🤖 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
`@Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift`
around lines 75 - 78, `AccountFlow` currently collapses billing management into
a Bool, which hides the difference between legacy/external billing and the
missing-customer anomaly. Update the `AccountFlow` contract and
`HostAccountFlow` mapping to preserve the full billing state (or add an explicit
unavailable case) instead of only exposing `canManageBilling`, then adjust
`ProUpgradeCard` to branch on that richer state so the anomaly can be handled
separately from `external` billing.

Source: Path instructions

Comment on lines +278 to 293
async function currentPlanSnapshot(): Promise<{
isPro: boolean;
billingManagement: "stripe" | "external" | "none";
}> {
if (!isStackConfigured()) {
return { isPro: false };
return { isPro: false, billingManagement: "none" };
}

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

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

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

Duplicate currentPlanSnapshot across pricing pages.

This function is duplicated verbatim (including the billingManagement addition) in web/app/app-pricing/page.tsx. Both copies must be kept in sync manually whenever the Pro/billing contract changes, risking drift between the two pricing surfaces. Consider extracting this into a shared helper (e.g. in web/services/billing/pro.ts or a shared pricing-server module) that both pages import.

🤖 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 278 - 293, The
currentPlanSnapshot logic is duplicated in multiple pricing page modules,
including the current pricing page and the app-pricing page, which risks the
Pro/billing contract drifting over time. Extract currentPlanSnapshot into a
shared helper used by both pages, keeping the existing isStackConfigured,
getStackServerApp().getUser, and resolveProPlanStatus flow in one place so
billingManagement stays consistent everywhere.

Comment on lines 32 to 46
const customerId = await stripeCustomerIdForStackUser(user.id);
if (!customerId) {
return pricingRedirect(request, "unavailable");
const status = await resolveProPlanStatus(user);
if (status.billingManagement === "stripe") {
captureBillingError(
new Error("Stripe-managed billing user is missing a Stripe customer row"),
{
route: "/api/billing/portal",
stackUserId: user.id,
billingManagement: status.billingManagement,
},
);
}
return pricingRedirect(request, "external");
}

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 | 🟡 Minor | ⚡ Quick win

Non-Pro users hitting the portal directly get a misleading "billing managed elsewhere" banner.

When billingManagement resolves to "none" (user was never Pro via Stack or Stripe), this code still redirects to billing=external, which renders external-billing messaging even though the user has no billing relationship at all. Only the "stripe" case is distinguished (for Sentry capture); "external" and "none" are conflated into the same redirect.

🩹 Proposed fix to distinguish the "none" case
     const customerId = await stripeCustomerIdForStackUser(user.id);
     if (!customerId) {
       const status = await resolveProPlanStatus(user);
       if (status.billingManagement === "stripe") {
         captureBillingError(
           new Error("Stripe-managed billing user is missing a Stripe customer row"),
           {
             route: "/api/billing/portal",
             stackUserId: user.id,
             billingManagement: status.billingManagement,
           },
         );
+        return pricingRedirect(request, "external");
+      }
+      if (status.billingManagement === "external") {
+        return pricingRedirect(request, "external");
       }
-      return pricingRedirect(request, "external");
+      return NextResponse.redirect(new URL("/pricing", request.url), 302);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const customerId = await stripeCustomerIdForStackUser(user.id);
if (!customerId) {
return pricingRedirect(request, "unavailable");
const status = await resolveProPlanStatus(user);
if (status.billingManagement === "stripe") {
captureBillingError(
new Error("Stripe-managed billing user is missing a Stripe customer row"),
{
route: "/api/billing/portal",
stackUserId: user.id,
billingManagement: status.billingManagement,
},
);
}
return pricingRedirect(request, "external");
}
const customerId = await stripeCustomerIdForStackUser(user.id);
if (!customerId) {
const status = await resolveProPlanStatus(user);
if (status.billingManagement === "stripe") {
captureBillingError(
new Error("Stripe-managed billing user is missing a Stripe customer row"),
{
route: "/api/billing/portal",
stackUserId: user.id,
billingManagement: status.billingManagement,
},
);
return pricingRedirect(request, "external");
}
if (status.billingManagement === "external") {
return pricingRedirect(request, "external");
}
return NextResponse.redirect(new URL("/pricing", request.url), 302);
}
🤖 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` around lines 32 - 46, The portal route
currently treats all non-Stripe cases the same, so users with billingManagement
"none" are redirected as if they had external billing. Update the logic in the
billing portal handler to distinguish the "none" case from "external" using
resolveProPlanStatus and pricingRedirect, and only use the external billing
redirect for actual external/managed billing users. Keep the existing
captureBillingError path in the stripe-managed missing-customer branch, and make
sure the redirect target reflects the user’s real billing state.

This branch was successfully deployed

1 active deployment
Preview – cmux — 53d6d3a9 Deployed Jul 7, 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