Repository navigation
Add dashboard billing section with plan cancel and resume - #7448
Conversation
/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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds 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. ChangesSelf-serve Billing Dashboard
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
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
🚥 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 |
Greptile SummaryThis PR adds
Confidence Score: 3/5The 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 Important Files Changed
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
%%{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
|
| return typeof lookupKey === "string" ? lookupKey : null; | ||
| } | ||
|
|
||
| function formatBillingDate(date: Date, locale: string): string { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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".
| const user = await getStackServerApp().getUser({ or: "return-null" }); | ||
| if (!user) { | ||
| redirect(vaultSignInHref(localizedVaultPath(locale, "/dashboard/billing"))); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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
📒 Files selected for processing (8)
skills/cmux-billing/SKILL.mdweb/app/[locale]/dashboard/billing/page.tsxweb/app/[locale]/dashboard/dashboard-shell.tsxweb/app/api/billing/subscription/route.tsweb/messages/en.jsonweb/messages/ja.jsonweb/tests/billing-subscription-route.test.tsweb/tests/dashboard-billing-page.test.tsx
| 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; | ||
| } |
There was a problem hiding this comment.
🎯 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
| try { | ||
| action = subscriptionAction(await request.formData()); | ||
| if (!action) { | ||
| return billingRedirect(request, "error"); | ||
| } | ||
|
|
||
| if (!isStackConfigured()) { | ||
| throw new Error("Billing subscription management is not configured"); | ||
| } |
There was a problem hiding this comment.
🎯 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.
| 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.
| const user = await currentStackUser(); | ||
| if (!user) { | ||
| return NextResponse.redirect( | ||
| new URL(vaultSignInHref(localizedVaultPath(requestLocale(request), "/dashboard/billing")), request.url), | ||
| 303, | ||
| ); | ||
| } | ||
| stackUserId = user.id; |
There was a problem hiding this comment.
🩺 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/nullRepository: 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:
- 1: https://git.littlediary.cn/CN-JS-HuiBai/hexclave/commits/commit/5341371782f298f58a1d7aef9c38070864672879/packages
- 2: https://docs.stack-auth.com/docs/getting-started/users
- 3: https://docs.stack-auth.com/docs/sdk/types/user
- 4: https://docs.stack-auth.com/rest-api/client/users/get-current-user
- 5: https://docs.stack-auth.com/docs/concepts/stack-app.mdx
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.
| 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; | ||
| } |
There was a problem hiding this comment.
🎯 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.
| 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.
/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
Need help on this PR? Tag
/codesmithwith 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/billingpage (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/subscriptionhandlescancelandresumevia plain HTML forms: same-origin checks (browserMutationOriginAllowed), session user (with anonymous purchaser fallback), lookup of the user’s active Stripe Pro row, Stripecancel_at_period_endupdate, localstripe_subscriptionssnapshot, then a localized 303 back to billing. Cancel uses a required confirmation checkbox on the page.Docs in
skills/cmux-billing/SKILL.mdare 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.
/dashboard/billingshows plan state: Free, Stripe Pro active, cancel pending, or legacy Pro.POST /api/billing/subscriptionvalidates 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 abillingbanner.billing=nosub./api/billing/portalwhen a Stripe customer exists.Written for commit 9c8d3a4. Summary will update on new commits.
Summary by CodeRabbit
New Features
Bug Fixes
Tests