Skip to content

Add dashboard billing section with plan cancel and resume - #7448

Merged
lawrencecchen merged 1 commit into
mainfrom
feat-dashboard-billing
Jul 6, 2026
Merged

lawrencecchen merged 1 commit into
mainfrom
feat-dashboard-billing

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

/dashboard/billing shows the current plan in four states (free, active Stripe Pro with price and renewal date, cancellation pending with the end date and a resume action, legacy Stack Pro with a managed-elsewhere note) plus localized result banners, wired into the dashboard shell nav. POST /api/billing/subscription performs cancel-at-period-end or resume: the subscription is derived strictly from the session user (anonymous purchaser fallback included), the action literal is validated, cross-origin form posts are rejected via the same browser-mutation origin check the VM routes use, Stripe is updated first and the local row synced idempotently alongside the webhook, then a 303 returns to the localized dashboard page. Plain no-JS confirm forms, en + ja.

/fable loop: judge APPROVE plus two hardening notes folded in (origin validation, junk-action coverage). Live-verified on a dev stack against a real test-mode purchase: cancel flipped cancel_at_period_end on Stripe and in the DB with the cancelled banner, resume flipped it back, both confirmed via the Stripe API. 393 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 live Stripe subscription state and billing DB rows, but changes are scoped to the authenticated (or anonymous purchaser) user with origin validation and test coverage.

Overview
Adds self-serve subscription management in the dashboard: a new /dashboard/billing page (en/ja) shows Free, active Stripe Pro (price, renewal/end date), pending cancellation with resume, or legacy Stack Pro, plus result banners from query params. Account → Billing is wired into the dashboard shell nav.

POST /api/billing/subscription handles cancel and resume via plain HTML forms: same-origin checks (browserMutationOriginAllowed), session user (with anonymous purchaser fallback), lookup of the user’s active Stripe Pro row, Stripe cancel_at_period_end update, local stripe_subscriptions snapshot, then a localized 303 back to billing. Cancel uses a required confirmation checkbox on the page.

Docs in skills/cmux-billing/SKILL.md are updated; route and page tests cover origin rejection, junk actions, auth redirects, and UI states.

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


Summary by cubic

Adds a new dashboard billing page with plan status and self-serve cancel/resume, plus a secure subscription endpoint with same-origin checks and localized banners.

  • New Features
    • /dashboard/billing shows plan state: Free, Stripe Pro active, cancel pending, or legacy Pro.
    • Displays price and renew/end date; shows localized result banners.
    • Adds “Billing” to the dashboard navigation.
    • Plain no-JS forms to cancel at period end or resume (en + ja).
    • POST /api/billing/subscription validates action, enforces same-origin, derives the subscription from the session user (with anonymous purchaser fallback), updates Stripe, syncs the local row, then 303-redirects to the localized billing page with a billing banner.
    • If no active Stripe subscription is found, redirects with billing=nosub.
    • Shows a link to /api/billing/portal when a Stripe customer exists.

Written for commit 9c8d3a4. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added a localized billing dashboard with plan status, renewal details, and self-serve billing actions.
    • Added support for canceling or resuming an active subscription from the dashboard.
    • Added billing navigation and localized labels in English and Japanese.
  • Bug Fixes

    • Improved handling for missing subscriptions, sign-in redirects, and error states with clear user banners.
  • Tests

    • Added coverage for billing page states and subscription cancel/resume flows.

/dashboard/billing shows the current plan with four states (free,
active Stripe Pro, cancellation pending, legacy Stack Pro) and
localized banners. POST /api/billing/subscription cancels at period
end or resumes, deriving the subscription strictly from the session
user (anonymous purchaser fallback included), origin-validated like
other browser mutations, updating Stripe then the local row
idempotently alongside the webhook. Nav entry in the dashboard shell;
en + ja; plain no-JS confirm forms.

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:59am
cmux-staging Building Building Preview, Comment Jul 6, 2026 8:59am

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds self-serve Stripe billing to the dashboard: a POST API route to cancel/resume subscriptions, a localized dashboard billing page rendering Free/Stripe/Legacy plan states, navigation entries, English/Japanese translations, and Bun test coverage for both the route and page.

Changes

Self-serve Billing Dashboard

Layer / File(s) Summary
Subscription cancel/resume API route
web/app/api/billing/subscription/route.ts
Adds a POST route validating browser-origin mutations, parsing cancel/resume actions, resolving the stack user, fetching the active Stripe Pro subscription, updating Stripe and persisting a DB snapshot, then redirecting with a locale-aware billing status.
Dashboard billing page and plan rendering
web/app/[locale]/dashboard/billing/page.tsx
Adds a server-rendered billing page that authenticates the user, loads translations, queries subscription/customer data, and renders Free, Stripe (with cancel/resume UI and portal link), or Legacy plan states with derived pricing and formatted dates.
Navigation wiring and localized strings
web/app/[locale]/dashboard/dashboard-shell.tsx, web/messages/en.json, web/messages/ja.json, skills/cmux-billing/SKILL.md
Adds a billing link to the dashboard account navigation group, introduces English and Japanese dashboard.billing translation strings, and documents the new route/page in the billing skill.
Route and page test suites
web/tests/billing-subscription-route.test.ts, web/tests/dashboard-billing-page.test.tsx
Adds Bun tests mocking Stripe, DB, and stack-auth modules to verify cancel/resume, auth fallback, no-subscription, and error scenarios for the route, and Free/Stripe/Legacy/banner rendering for the page.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant BillingRoute as POST /api/billing/subscription
  participant Stripe
  participant DB as cloudDb

  User->>BillingRoute: submit cancel/resume form
  BillingRoute->>BillingRoute: validate origin and action
  BillingRoute->>DB: latestActiveStripeSubscription(stackUserId)
  DB-->>BillingRoute: subscription row
  BillingRoute->>Stripe: subscriptions.update(cancel_at_period_end)
  Stripe-->>BillingRoute: updated subscription
  BillingRoute->>DB: update stripeSubscriptions snapshot
  BillingRoute-->>User: redirect with billing status banner
Loading
sequenceDiagram
  participant User
  participant BillingPage as DashboardBillingPage
  participant DB as cloudDb
  participant Stripe

  User->>BillingPage: request /dashboard/billing
  BillingPage->>BillingPage: resolve user, locale, translations
  BillingPage->>DB: latestActiveStripeSubscription + hasCustomerRow
  DB-->>BillingPage: subscription row, customer existence
  BillingPage->>BillingPage: compute banner, derive price from raw payload
  BillingPage-->>User: render Free, Stripe, or Legacy plan UI
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 21

❌ Failed checks (1 warning, 20 inconclusive)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the feature and testing, but it doesn't follow the required template sections or include the review trigger and checklist. Rewrite it to match the template with Summary, Testing, Demo Video, Review Trigger, and Checklist sections, and fill in any missing 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 clearly summarizes the main change: adding dashboard billing with cancel/resume subscription actions.
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-dashboard-billing

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.

@greptile-apps

greptile-apps Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds /dashboard/billing (four plan states: free, active Stripe Pro, pending cancellation, legacy Stack Pro) and POST /api/billing/subscription (cancel-at-period-end / resume), wired into the dashboard shell nav with en and ja translations.

  • The route validates origin, action literal, and Stack auth before touching Stripe, syncs the local DB row after the Stripe call, and returns 303 redirects with banner params — well-structured and covered by a thorough test suite.
  • New dashboard.billing message keys and nav labels are only added to en.json and ja.json; the 18 other supported locales are missing all new keys. Two user-facing strings (banners.nosub, legacy.body) name internal vendors (Stripe, Stack) which violates the cmux copy policy.
  • priceCopy() returns hardcoded English strings (\"$30/month\", \"$240/year\") outside the i18n system, so price labels remain in English for all non-English locales.

Confidence Score: 3/5

The route logic and test coverage are solid, but the billing page and message files have three issues affecting all non-English users before this can ship cleanly.

Translations were only added for two of the twenty supported locales, so users on the other eighteen locales will see raw message keys or English fallbacks in the nav and on the billing page. Price labels are hardcoded in English outside the i18n system, and two user-facing strings name internal vendor services in violation of the copy policy. The route implementation and tests are well-constructed; the gaps are in the message catalog and copy.

All 18 locale message files under web/messages/ that were not updated, plus web/app/[locale]/dashboard/billing/page.tsx (priceCopy) and the banners.nosub / legacy.body entries in en.json and ja.json.

Important Files Changed

Filename Overview
web/app/[locale]/dashboard/billing/page.tsx New billing dashboard page rendering four plan states. Hardcoded price strings in priceCopy() bypass the i18n system, causing English-only labels for all non-English locales.
web/app/api/billing/subscription/route.ts New POST route for cancel/resume: validates origin, action literal, Stack user, and active subscription before mutating. Logic and security structure look sound.
web/messages/en.json Adds dashboard.billing and nav keys. Two user-facing strings expose vendor names (Stripe, Stack). The 18 other locale files are not updated.
web/messages/ja.json Adds Japanese translations but carries the same vendor-name exposure issues. The remaining 18 locales are not covered.
web/app/[locale]/dashboard/dashboard-shell.tsx Adds the account/billing nav group with active-state check; consistent with existing nav patterns.
web/tests/billing-subscription-route.test.ts Good coverage: cancel, resume, cross-origin rejection, junk action, unauthenticated redirect, anonymous purchaser fallback, nosub, and Stripe failure are all tested.
web/tests/dashboard-billing-page.test.tsx Covers all four plan states and all four banner variants with server-rendered HTML assertions.
skills/cmux-billing/SKILL.md Adds a one-line doc entry for the new route and page — routine documentation update.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Browser
    participant BillingPage as /dashboard/billing
    participant SubRoute as POST /api/billing/subscription
    participant StackAuth as Stack Auth
    participant DB as cloudDb
    participant Stripe as Stripe API

    Browser->>BillingPage: GET (auth'd)
    BillingPage->>StackAuth: getUser()
    BillingPage->>DB: latestActiveStripeSubscription()
    BillingPage->>DB: hasCustomerRow()
    BillingPage-->>Browser: Render plan state

    Browser->>SubRoute: "POST action=cancel|resume"
    SubRoute->>SubRoute: browserMutationOriginAllowed()
    SubRoute->>SubRoute: subscriptionAction() validate
    SubRoute->>StackAuth: getUser()
    SubRoute->>DB: activeStripeSubscriptionForStackUser()
    SubRoute->>Stripe: subscriptions.update(cancel_at_period_end)
    SubRoute->>DB: updateSubscriptionSnapshot()
    SubRoute-->>Browser: 303 redirect with billing param
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 Browser
    participant BillingPage as /dashboard/billing
    participant SubRoute as POST /api/billing/subscription
    participant StackAuth as Stack Auth
    participant DB as cloudDb
    participant Stripe as Stripe API

    Browser->>BillingPage: GET (auth'd)
    BillingPage->>StackAuth: getUser()
    BillingPage->>DB: latestActiveStripeSubscription()
    BillingPage->>DB: hasCustomerRow()
    BillingPage-->>Browser: Render plan state

    Browser->>SubRoute: "POST action=cancel|resume"
    SubRoute->>SubRoute: browserMutationOriginAllowed()
    SubRoute->>SubRoute: subscriptionAction() validate
    SubRoute->>StackAuth: getUser()
    SubRoute->>DB: activeStripeSubscriptionForStackUser()
    SubRoute->>Stripe: subscriptions.update(cancel_at_period_end)
    SubRoute->>DB: updateSubscriptionSnapshot()
    SubRoute-->>Browser: 303 redirect with billing param
Loading

Comments Outside Diff (2)

  1. web/messages/en.json, line 488 (link)

    P1 Missing translations for 18 supported locales

    The repo supports 20 locales (en, ja, zh-CN, zh-TW, ko, de, es, fr, it, da, pl, ru, bs, ar, no, pt-BR, th, tr, km, uk — all defined in web/i18n/routing.ts), but this PR only adds the new dashboard.billing and nav keys to en.json and ja.json. The other 18 message files are missing all of the new keys. In production, users on those locales will either see raw message keys or fall through to English copy, and the billing nav item will similarly be broken for them.

  2. web/messages/en.json, line 488-503 (link)

    P1 User-facing copy exposes vendor/implementation names

    Two strings shown directly to users name internal vendors: banners.nosub says "No active Stripe subscription was found" and legacy.body mentions both "Stack" (internal auth service) and "Stripe". Per the cmux copy policy, error banners and plan descriptions should use cmux-level terms. The nosub banner could read "No active subscription was found for this account." The same applies to ja.json.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "Add dashboard billing section with plan ..." | Re-trigger Greptile

Comment on lines +261 to +264
return typeof lookupKey === "string" ? lookupKey : null;
}

function formatBillingDate(date: Date, locale: string): string {

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 Hardcoded price strings bypass the i18n system

priceCopy() returns bare English-hardcoded strings ("$30/month", "$240/year") directly from TypeScript rather than routing them through getTranslations. Users on any of the 19 non-English locales will see these labels in English. The /month and /year suffixes need to live in the message catalog so translators can adapt them.

@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: 9c8d3a49b5

ℹ️ 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 +44 to +46
const user = await getStackServerApp().getUser({ or: "return-null" });
if (!user) {
redirect(vaultSignInHref(localizedVaultPath(locale, "/dashboard/billing")));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle anonymous purchasers on billing page

For Stripe Pro purchases started while signed out, checkout attaches the subscription to an anonymous Stack user, and the portal/subscription routes explicitly recover that session with anonymous-if-exists[deprecated]. This page only asks for return-null and immediately redirects when that returns null, so those purchasers cannot reach the new cancel/resume forms from /dashboard/billing until they add a sign-in method. Use the same anonymous-user fallback here before redirecting.

Useful? React with 👍 / 👎.

@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 `@web/app/`[locale]/dashboard/billing/page.tsx:
- Around line 241-262: The displayed plan price in priceCopy is hardcoded with
literal dollar strings instead of using the locale catalog. Update the billing
page so the price text is resolved through next-intl via t(...) (for example for
the monthly and yearly pro plan labels) rather than returning raw strings from
priceCopy, and keep priceLookupKey only for selecting the right translation key.
Add the matching translation entries for every locale in web/messages so the
displayed plan price is localized consistently.

In `@web/app/api/billing/subscription/route.ts`:
- Around line 34-42: The cancel flow in the subscription route is missing
server-side confirmation validation, so direct POSTs can bypass the UI checkbox.
Update the `subscriptionAction`/`billing subscription` handler in `route.ts` to
read and verify the `confirm` field from `request.formData()` before allowing
the cancel action to proceed, and redirect to the error path when it is absent
or unchecked. Keep the existing `action` validation, but ensure the cancel
branch in this handler enforces the same confirmation gate that `page.tsx`
expects.
- Around line 121-150: The post-action redirect locale is being inferred from
the Referer header in requestLocale, which can fail silently and fall back to
the default locale. Update the billing flow so the locale is passed explicitly
from the page/forms instead of derived from Referer, and have
billingRedirect/localizedBillingPath use that submitted locale to build the
redirect URL. Ensure the relevant billing subscription form handlers and the
requestLocale helper are updated consistently so the banner/redirect matches the
user’s actual locale.
- Around line 44-51: The subscription route is still relying on the deprecated
Stack Auth anonymous fallback behavior around currentStackUser, which can treat
anonymous purchasers as signed out. Update the logic in the route handler to use
the current Stack Auth-supported identity flow instead of any
anonymous-if-exists assumption, and make sure stackUserId is derived from the
authenticated/recognized user path that Stack Auth now recommends. Keep the
redirect fallback for missing users, but adjust the user lookup in this handler
so subscription management continues to work for anonymous purchasers under the
current SDK/docs.
🪄 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: bcd14012-8594-4972-9de7-ced3dc0b1d03

📥 Commits

Reviewing files that changed from the base of the PR and between eef0bcf and 9c8d3a4.

📒 Files selected for processing (8)
  • skills/cmux-billing/SKILL.md
  • web/app/[locale]/dashboard/billing/page.tsx
  • web/app/[locale]/dashboard/dashboard-shell.tsx
  • web/app/api/billing/subscription/route.ts
  • web/messages/en.json
  • web/messages/ja.json
  • web/tests/billing-subscription-route.test.ts
  • web/tests/dashboard-billing-page.test.tsx

Comment on lines +241 to +262
function priceCopy(subscription: StripeSubscriptionRow): string | null {
const lookupKey = priceLookupKey(subscription) ?? subscription.priceId;
if (lookupKey === "cmux-pro-monthly") return "$30/month";
if (lookupKey === "cmux-pro-yearly") return "$240/year";
return null;
}

function priceLookupKey(subscription: StripeSubscriptionRow): string | null {
const raw = subscription.raw;
const items = raw && typeof raw === "object" ? raw.items : null;
const data = items && typeof items === "object" && "data" in items
? (items as { data?: unknown }).data
: null;
const firstItem = Array.isArray(data) ? data[0] : null;
const price = firstItem && typeof firstItem === "object" && "price" in firstItem
? (firstItem as { price?: unknown }).price
: null;
const lookupKey = price && typeof price === "object" && "lookup_key" in price
? (price as { lookup_key?: unknown }).lookup_key
: null;
return typeof lookupKey === "string" ? lookupKey : null;
}

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

Displayed plan price bypasses the i18n catalog entirely.

priceCopy returns raw literal strings "$30/month" / "$240/year" instead of a t(...) lookup. Every other string in this page goes through next-intl; this one doesn't, so it renders identically (and untranslated) regardless of locale, and any future currency/format localization has no hook. As per coding guidelines, .github/review-bot-rules/full-internationalization.md requires that "web UI, metadata, API response, rendered markdown, changelog, or user-facing data changes must read from locale-specific sources and update every locale listed in web/i18n/routing.ts with matching web/messages/ entries."

🌐 Proposed fix: source price copy from the translation catalog
 function priceCopy(subscription: StripeSubscriptionRow): string | null {
   const lookupKey = priceLookupKey(subscription) ?? subscription.priceId;
-  if (lookupKey === "cmux-pro-monthly") return "$30/month";
-  if (lookupKey === "cmux-pro-yearly") return "$240/year";
-  return null;
 }

Call it with t instead, e.g. t("pro.priceMonthly") / t("pro.priceYearly"), and add the matching keys to both web/messages/en.json and web/messages/ja.json:

"pro": {
  "priceMonthly": "$30/month",
  "priceYearly": "$240/year"
}
🤖 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]/dashboard/billing/page.tsx around lines 241 - 262, The
displayed plan price in priceCopy is hardcoded with literal dollar strings
instead of using the locale catalog. Update the billing page so the price text
is resolved through next-intl via t(...) (for example for the monthly and yearly
pro plan labels) rather than returning raw strings from priceCopy, and keep
priceLookupKey only for selecting the right translation key. Add the matching
translation entries for every locale in web/messages so the displayed plan price
is localized consistently.

Sources: Coding guidelines, Path instructions

Comment on lines +34 to +42
try {
action = subscriptionAction(await request.formData());
if (!action) {
return billingRedirect(request, "error");
}

if (!isStackConfigured()) {
throw new Error("Billing subscription management is not configured");
}

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

Cancel confirmation checkbox isn't verified server-side.

The dashboard's cancel form requires a confirm checkbox client-side (page.tsx line ~186), but this handler only checks action. Any direct POST (curl, disabled JS, browser extension) cancels immediately without the confirmation gate the UI implies exists.

🛡️ Proposed fix to enforce confirmation server-side
-    action = subscriptionAction(await request.formData());
+    const formData = await request.formData();
+    action = subscriptionAction(formData);
     if (!action) {
       return billingRedirect(request, "error");
     }
+    if (action === "cancel" && formData.get("confirm") !== "yes") {
+      return billingRedirect(request, "error");
+    }
📝 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
try {
action = subscriptionAction(await request.formData());
if (!action) {
return billingRedirect(request, "error");
}
if (!isStackConfigured()) {
throw new Error("Billing subscription management is not configured");
}
try {
const formData = await request.formData();
action = subscriptionAction(formData);
if (!action) {
return billingRedirect(request, "error");
}
if (action === "cancel" && formData.get("confirm") !== "yes") {
return billingRedirect(request, "error");
}
if (!isStackConfigured()) {
throw new Error("Billing subscription management is not configured");
}
🤖 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/subscription/route.ts` around lines 34 - 42, The cancel
flow in the subscription route is missing server-side confirmation validation,
so direct POSTs can bypass the UI checkbox. Update the
`subscriptionAction`/`billing subscription` handler in `route.ts` to read and
verify the `confirm` field from `request.formData()` before allowing the cancel
action to proceed, and redirect to the error path when it is absent or
unchecked. Keep the existing `action` validation, but ensure the cancel branch
in this handler enforces the same confirmation gate that `page.tsx` expects.

Comment on lines +44 to +51
const user = await currentStackUser();
if (!user) {
return NextResponse.redirect(
new URL(vaultSignInHref(localizedVaultPath(requestLocale(request), "/dashboard/billing")), request.url),
303,
);
}
stackUserId = user.id;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n "anonymous-if-exists" -g '*.ts' -g '*.tsx'
rg -n "or:\s*[\"']return-null[\"']" -g '*.ts' web/app/lib/stack.ts
sed -n '1,80p' web/app/lib/stack.ts 2>/dev/null

Repository: manaflow-ai/cmux

Length of output: 1984


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files 'web/app/api/billing/subscription/route.ts' 'web/app/lib/stack.ts' 'web/app/**/*stack*.ts' 'web/app/**/*stack*.tsx' | sed -n '1,120p'
printf '\n--- route ---\n'
sed -n '1,140p' web/app/api/billing/subscription/route.ts
printf '\n--- stack ---\n'
sed -n '1,220p' web/app/lib/stack.ts
printf '\n--- anonymous-if-exists refs ---\n'
rg -n "anonymous-if-exists|return-null|currentStackUser|getUser\\(" web/app -g '*.ts' -g '*.tsx'

Repository: manaflow-ai/cmux

Length of output: 1955


🌐 Web query:

"anonymous-if-exists[deprecated]" Stack Auth getUser or return-null docs

💡 Result:

The term anonymous-if-exists is not a standard or documented configuration option in the current Stack Auth (stack-auth.com) SDK. It appears to be related to internal project testing or codebase history [1] rather than public API functionality. In the current Stack Auth SDK, user retrieval is handled primarily through the following methods: 1. Client Components: Use the useUser hook. By default, this hook returns null if the user is not signed in [2][3]. You can modify its behavior using the or option, such as { or: "redirect" } [4]. 2. Server Components: Use the await stackServerApp.getUser method, which retrieves the current user [5]. There is no "anonymous-if-exists" parameter associated with these functions in the official documentation. If you are encountering this term in a codebase, it is likely an internal or deprecated identifier [1] that should not be used in production applications. Always refer to the official Stack Auth documentation for supported SDK parameters [2][4][5].

Citations:


Replace the deprecated Stack Auth fallback

anonymous-if-exists[deprecated] isn’t part of the current Stack Auth docs. If the SDK drops it, anonymous purchasers will be treated as signed out and lose subscription management access.

🤖 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/subscription/route.ts` around lines 44 - 51, The
subscription route is still relying on the deprecated Stack Auth anonymous
fallback behavior around currentStackUser, which can treat anonymous purchasers
as signed out. Update the logic in the route handler to use the current Stack
Auth-supported identity flow instead of any anonymous-if-exists assumption, and
make sure stackUserId is derived from the authenticated/recognized user path
that Stack Auth now recommends. Keep the redirect fallback for missing users,
but adjust the user lookup in this handler so subscription management continues
to work for anonymous purchasers under the current SDK/docs.

Comment on lines +121 to +150
function billingRedirect(
request: NextRequest,
billing: "cancelled" | "resumed" | "nosub" | "error",
) {
const url = new URL(localizedBillingPath(request), request.url);
url.searchParams.set("billing", billing);
return NextResponse.redirect(url, 303);
}

function localizedBillingPath(request: NextRequest): string {
const locale = requestLocale(request);
return locale === routing.defaultLocale
? "/dashboard/billing"
: `/${locale}/dashboard/billing`;
}

function requestLocale(request: NextRequest): string {
const referer = request.headers.get("referer");
if (referer) {
try {
const firstSegment = new URL(referer).pathname.split("/").filter(Boolean)[0];
if (locales.includes(firstSegment as (typeof locales)[number])) {
return firstSegment;
}
} catch {
// Ignore malformed referers and fall back to the default locale.
}
}
return routing.defaultLocale;
}

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

Post-action redirect locale is derived only from the Referer header — a heuristic that can silently fail.

requestLocale parses Referer to pick the locale for the redirect/banner. Browsers/extensions that strip or restrict referrers (privacy modes, Referrer-Policy: no-referrer, ad-blockers) will silently fall back to the default locale, showing an English banner to a Japanese user after cancel/resume. The page already knows its own locale at render time — threading it through the form is a reliable signal instead of an inferred one.

🌐 Proposed fix: pass locale explicitly instead of inferring from Referer
-function requestLocale(request: NextRequest): string {
+function requestLocale(request: NextRequest, formLocale?: FormDataEntryValue | null): string {
+  if (typeof formLocale === "string" && locales.includes(formLocale as (typeof locales)[number])) {
+    return formLocale;
+  }
   const referer = request.headers.get("referer");

And in page.tsx, add a hidden field to both forms:

<input type="hidden" name="locale" value={locale} />
📝 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
function billingRedirect(
request: NextRequest,
billing: "cancelled" | "resumed" | "nosub" | "error",
) {
const url = new URL(localizedBillingPath(request), request.url);
url.searchParams.set("billing", billing);
return NextResponse.redirect(url, 303);
}
function localizedBillingPath(request: NextRequest): string {
const locale = requestLocale(request);
return locale === routing.defaultLocale
? "/dashboard/billing"
: `/${locale}/dashboard/billing`;
}
function requestLocale(request: NextRequest): string {
const referer = request.headers.get("referer");
if (referer) {
try {
const firstSegment = new URL(referer).pathname.split("/").filter(Boolean)[0];
if (locales.includes(firstSegment as (typeof locales)[number])) {
return firstSegment;
}
} catch {
// Ignore malformed referers and fall back to the default locale.
}
}
return routing.defaultLocale;
}
function billingRedirect(
request: NextRequest,
billing: "cancelled" | "resumed" | "nosub" | "error",
) {
const url = new URL(localizedBillingPath(request), request.url);
url.searchParams.set("billing", billing);
return NextResponse.redirect(url, 303);
}
function localizedBillingPath(request: NextRequest): string {
const locale = requestLocale(request);
return locale === routing.defaultLocale
? "/dashboard/billing"
: `/${locale}/dashboard/billing`;
}
function requestLocale(request: NextRequest, formLocale?: FormDataEntryValue | null): string {
if (typeof formLocale === "string" && locales.includes(formLocale as (typeof locales)[number])) {
return formLocale;
}
const referer = request.headers.get("referer");
if (referer) {
try {
const firstSegment = new URL(referer).pathname.split("/").filter(Boolean)[0];
if (locales.includes(firstSegment as (typeof locales)[number])) {
return firstSegment;
}
} catch {
// Ignore malformed referers and fall back to the default locale.
}
}
return routing.defaultLocale;
}
🤖 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/subscription/route.ts` around lines 121 - 150, The
post-action redirect locale is being inferred from the Referer header in
requestLocale, which can fail silently and fall back to the default locale.
Update the billing flow so the locale is passed explicitly from the page/forms
instead of derived from Referer, and have billingRedirect/localizedBillingPath
use that submitted locale to build the redirect URL. Ensure the relevant billing
subscription form handlers and the requestLocale helper are updated consistently
so the banner/redirect matches the user’s actual locale.

This branch was successfully deployed

1 active deployment
Preview – cmux — 9c8d3a49 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