Repository navigation
Gate billing management by kind and polish the pricing pages - #7479
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughIntroduces a ChangesBilling Management Differentiation
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
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 21❌ Failed checks (1 warning, 20 inconclusive)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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"); |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 53d6d3a. Configure here.
Greptile SummaryThis PR gates billing management by the new billing-management kind. The main changes are:
Confidence Score: 4/5The 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.
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
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; |
There was a problem hiding this comment.
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")} |
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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 winRun the two independent subscription lookups concurrently.
stackActiveandstripeActiveare independent I/O calls (Stack APIlistProducts+ a DB query) but are awaited sequentially. SinceresolveProPlanStatusruns on hot paths (pricing page SSR viacurrentPlanSnapshot, the/api/billing/planroute hit on every load, and the portal redirect), running them withPromise.allwould 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 winNested ternary chain keeps growing.
The
messagederivation is now an 8-branch nested ternary spanningwelcomeandbillingparams. 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 bywelcome/billingvalue 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 winReuse the shared
BillingManagementKindtype instead of re-declaring the union.
AppPlanSnapshot.billingManagementre-declares"stripe" | "external" | "none"inline, duplicating the type already introduced inweb/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
📒 Files selected for processing (19)
Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swiftPackages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swiftResources/Localizable.xcstringsSources/Auth/HostAccountFlow.swiftweb/app/[locale]/components/pro-welcome-banner.tsxweb/app/[locale]/pricing/page.tsxweb/app/api/billing/plan/route.tsweb/app/api/billing/portal/route.tsweb/app/app-pricing/page.tsxweb/app/components/pricing-shared.tsxweb/messages/en.jsonweb/messages/ja.jsonweb/services/billing/pro.tsweb/tests/app-pricing-page.test.tsxweb/tests/billing-plan-route.test.tsweb/tests/billing-portal-route.test.tsweb/tests/billing-pro.test.tsweb/tests/dashboard-billing-page.test.tsxweb/tests/pricing-page.test.tsx
|
|
||
| /// Whether the current Pro entitlement can be managed through the hosted | ||
| /// Stripe billing portal. | ||
| var canManageBilling: Bool { get } |
There was a problem hiding this comment.
🎯 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.tsxRepository: 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"
doneRepository: 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.tsxRepository: 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
| 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 }; | ||
| } |
There was a problem hiding this comment.
📐 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.
| 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"); | ||
| } |
There was a problem hiding this comment.
🎯 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.
| 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.


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
Need help on this PR? Tag
/codesmithwith 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) fromresolveProPlanStatusand 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, andbilling=unavailablestays for Stripe-not-configured. Anomalous Stripe-managed users missing astripe_customersrow trigger Sentry and the same external redirect.macOS adds
canManageBillingonAccountFlow; 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
billingManagement=stripe|external|noneand exposed it via the plan API.AccountFlow.canManageBillingand updated the Pro card to show the portal button only when Stripe-managed.stripe; legacy users see a localized “managed elsewhere” message.Bug Fixes
pricing?billing=external;unavailableis reserved for Stripe-unconfigured.Written for commit 53d6d3a. Summary will update on new commits.
Summary by CodeRabbit
New Features
Bug Fixes
Tests