Add Stripe billing with free/pro tiers and invite-assigned plans - #792
Conversation
…sync - 'free' plan tier with tight limits (data-grounded pricing: free / $2.50 personal / $10 pro, benchmarked against IFTTT, Make, Val Town) - invites.plan column applied to users at signup (password + social) - users.stripe_customer_id / stripe_plan cache with checkout-session linking guarded by client_reference_id and hourly cron refresh - getUserPlan resolves the effective plan as max(manual, stripe), with NULL legacy/unlimited never downgraded - /account/billing page routes, payment-link config, deploy secret sync
Wire AccountBillingRoute into the client router and account nav so users can view their plan, open Stripe checkout links, and manage subscriptions.
- /account/billing client route with plan cards (free / $2.50 personal / $5 pro), payment links, portal link, and nav entry - billing module unit tests (config, client, sync) plus entitlements coverage for the free tier and effective-plan resolution - test schemas updated for the new users billing columns - docs: entitlements architecture, env vars, setup manifest - regenerated worker-configuration.d.ts for the new production vars
- Manage subscription now forces a full-page navigation so the SPA router does not intercept the server-only /account/billing/portal redirect route - plan cards distinguish 'Current plan' (exact match) from 'Included in your plan' (higher-ranked or legacy/unlimited plan)
Independent review findings: - client_reference_id is now an HMAC of the stable user id keyed by COOKIE_SECRET instead of the raw email hash, so it cannot be derived by an attacker who knows a victim's email - an established stripe_customer_id is never silently replaced by a later checkout session (GET endpoint; first-link or same-customer only) - checkout session ids are redacted from Stripe error logs - documented the portal redirect trust assumption and the committed production price id / payment link coupling
|
🔎 Preview deployed: https://kody-pr-792.kody-a99.workers.dev Worker: Mocks:
|
One free tier plus one paid tier is easier to justify and communicate. $5/mo sits between IFTTT Pro ($3.99) and Make (~$10), stays far from the $20 ChatGPT anchor, and covers worst-case per-user infra (~$1-2/mo of DO duration) with real margin. The Stripe Personal product, price, and payment link are deactivated; parsePlanName already treats stored 'personal' values as NULL so no data migration is needed.
|
Warning Review limit reached
Next review available in: 47 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThis change adds invite-associated plans, replaces ChangesInvite Plans and Stripe Billing
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 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.
Actionable comments posted: 1
🧹 Nitpick comments (2)
packages/worker/src/app/handlers/auth.ts (1)
1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicate invite-plan consumption logic across password and OAuth signup. Both handlers implement identical
consumedInvitePlanderivation, invite-release reset, and conditionalplaninsertion; the shared root cause is that invite-plan-to-user-creation logic isn't factored into a single helper.
packages/worker/src/app/handlers/auth.ts#L202-254: extract a shared helper (e.g. inpackages/worker/src/app/invites.ts) that consumes the invite, derivesconsumedInvitePlanviaparsePlanName, and returns the fields to spread into user creation, plus arelease()closure; call it here.packages/worker/src/app/handlers/auth-provider.ts#L437-484: call the same shared helper here instead of re-implementing the state machine.🤖 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/worker/src/app/handlers/auth.ts` at line 1, Extract the duplicated invite-plan state machine from the password signup flow and OAuth signup flow into a shared helper, such as in invites.ts. The helper should consume the invite, derive consumedInvitePlan through parsePlanName, return user-creation fields including conditional plan data, and expose a release() closure for resetting the invite; update the handlers around auth.ts and auth-provider.ts to call this helper and remove their duplicate logic.packages/worker/src/app/account-billing-data.ts (1)
75-102: 🧹 Nitpick | 🔵 TrivialOn-render Stripe refresh adds request-path latency.
The staleness path calls
refreshStripePlanForUser(a blockinglistSubscriptionscall to Stripe) during page/API render. It's correctly gated to 60s and wrapped so failures degrade gracefully, and the hourly cron lane provides a backstop — so this is acceptable. If billing-page p95 latency becomes a concern, consider serving the cachedstripe_planimmediately and refreshing out-of-band (e.g.waitUntil) instead of awaiting Stripe inline.🤖 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/worker/src/app/account-billing-data.ts` around lines 75 - 102, The current staleness path awaits refreshStripePlanForUser during request rendering; if reducing billing-page latency, return the cached stripePlan immediately and move the Stripe refresh to an out-of-band waitUntil task, preserving the existing 60-second staleness gate and graceful error logging.
🤖 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/worker/src/billing/stripe-client.ts`:
- Around line 88-120: Update stripeRequest to create a request timeout and pass
its AbortSignal through the fetch RequestInit. Ensure the signal automatically
aborts slow or hung Stripe requests while preserving the existing URL, headers,
method, and body handling.
---
Nitpick comments:
In `@packages/worker/src/app/account-billing-data.ts`:
- Around line 75-102: The current staleness path awaits refreshStripePlanForUser
during request rendering; if reducing billing-page latency, return the cached
stripePlan immediately and move the Stripe refresh to an out-of-band waitUntil
task, preserving the existing 60-second staleness gate and graceful error
logging.
In `@packages/worker/src/app/handlers/auth.ts`:
- Line 1: Extract the duplicated invite-plan state machine from the password
signup flow and OAuth signup flow into a shared helper, such as in invites.ts.
The helper should consume the invite, derive consumedInvitePlan through
parsePlanName, return user-creation fields including conditional plan data, and
expose a release() closure for resetting the invite; update the handlers around
auth.ts and auth-provider.ts to call this helper and remove their duplicate
logic.
🪄 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: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 53e0912c-fa43-485b-9122-f9e5466872a7
📒 Files selected for processing (64)
.github/workflows/deploy.ymldocs/contributing/architecture/authentication.mddocs/contributing/architecture/entitlements.mddocs/contributing/architecture/primitives.yamldocs/contributing/environment-variables.mddocs/contributing/setup-manifest.mde2e/invite-signup-verification.spec.tspackages/worker/.env.examplepackages/worker/client/routes/account-billing.tsxpackages/worker/client/routes/account-management-components.tsxpackages/worker/client/routes/admin-insights.tsxpackages/worker/client/routes/admin-invites.tsxpackages/worker/client/routes/index.tsxpackages/worker/migrations/0065-invite-plans.sqlpackages/worker/migrations/0066-stripe-billing.sqlpackages/worker/src/app/account-billing-data.tspackages/worker/src/app/admin-invites-data.tspackages/worker/src/app/admin-user-usage-data.node.test.tspackages/worker/src/app/admin-user-usage-data.tspackages/worker/src/app/handlers/account-billing.tspackages/worker/src/app/handlers/admin-invites.tspackages/worker/src/app/handlers/admin-users.node.test.tspackages/worker/src/app/handlers/auth-handler.node.test.tspackages/worker/src/app/handlers/auth-provider.tspackages/worker/src/app/handlers/auth.tspackages/worker/src/app/invites.node.test.tspackages/worker/src/app/invites.tspackages/worker/src/app/loader-data.tspackages/worker/src/app/router.tspackages/worker/src/app/routes.tspackages/worker/src/billing/billing-config.node.test.tspackages/worker/src/billing/billing-config.tspackages/worker/src/billing/stripe-client.node.test.tspackages/worker/src/billing/stripe-client.tspackages/worker/src/billing/subscription-sync.tspackages/worker/src/billing/subscription-sync.workers.test.tspackages/worker/src/community/community-flow-test-schema.tspackages/worker/src/db.tspackages/worker/src/email/inbound-entitlements.workers.test.tspackages/worker/src/email/inbound.tspackages/worker/src/email/outbound.workers.test.tspackages/worker/src/entitlements/entitlements-service.workers.test.tspackages/worker/src/entitlements/entitlements.node.test.tspackages/worker/src/entitlements/plans.tspackages/worker/src/entitlements/service.tspackages/worker/src/entitlements/test-schema.tspackages/worker/src/env-schema.tspackages/worker/src/index.tspackages/worker/src/index.workers.test.tspackages/worker/src/jobs/service.node.test.tspackages/worker/src/mcp/capabilities/admin/admin-capabilities.node.test.tspackages/worker/src/mcp/capabilities/admin/admin-user-usage.tspackages/worker/src/mcp/capabilities/email/email-usage-get.workers.test.tspackages/worker/src/mcp/capabilities/packages/save-package-entitlements.node.test.tspackages/worker/src/mcp/capabilities/packages/save-package-private-visibility.node.test.tspackages/worker/src/mcp/capabilities/repo/repo-open-session.node.test.tspackages/worker/src/mcp/capabilities/services/service-start.node.test.tspackages/worker/src/mcp/executor.node.test.tspackages/worker/src/mcp/secrets/service.node.test.tspackages/worker/src/package-registry/service.node.test.tspackages/worker/src/package-runtime/package-workflows.node.test.tspackages/worker/src/storage-runner.workers.test.tspackages/worker/worker-configuration.d.tspackages/worker/wrangler.jsonc
…nal plan migration - billing page always refreshes subscription state on view so a scheduled cancellation date is never hidden by the stored cache - a failed plan refresh right after checkout linking no longer surfaces as a checkout error (page view and cron converge shortly) - migration 0067 maps stored 'personal' plans to 'pro' so accounts granted the removed tier keep enforcement instead of falling back to legacy/unlimited
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ca0358e. Configure here.
| <label mix={css(fieldCss)}> | ||
| <span mix={css(fieldLabelCss)}>Plan</span> | ||
| <select name="plan" disabled={isMutating} mix={css(inputCss)}> | ||
| <option value="">Legacy / tierless (free)</option> |
There was a problem hiding this comment.
Invite default mislabeled as free
Medium Severity
The create-invite plan dropdown labels the empty option as “Legacy / tierless (free)”, but an empty value stores a null invite plan, which signup applies as a null users.plan (legacy/unlimited), not the enforced free tier. Admins can pick that option believing they assigned free limits while new users remain tierless.
Reviewed by Cursor Bugbot for commit ca0358e. Configure here.
| 'missing_customer', | ||
| 'The checkout session did not include a Stripe customer.', | ||
| ) | ||
| } |
There was a problem hiding this comment.
Checkout success skips payment check
Medium Severity
The billing success handler links stripe_customer_id after fetching a checkout session but never verifies the session is completed or paid. It only checks HMAC client_reference_id and presence of customer, so an unpaid or incomplete session can still establish linkage and trigger the “already linked to a different customer” guard on a later successful checkout.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit ca0358e. Configure here.
|
|
||
| export function parseStoredStripePlan(value: string | null | undefined) { | ||
| return parsePlanName(value) | ||
| } |
There was a problem hiding this comment.
Unused exported billing helper
Low Severity
parseStoredStripePlan is exported from the new subscription sync module but nothing in the repository imports or calls it, so it is dead code left over from the billing work.
Reviewed by Cursor Bugbot for commit ca0358e. Configure here.


What
Lightweight (gratitext-style, no SDK, no webhooks) Stripe billing plus the invite/plan plumbing to run cohorts on different tiers:
freeplan (tight limits: 5 packages, 3 jobs, 5 email sends/day, 64 MB storage, …) andproat $5/mo;partnerremains as a manual grant. Plan order:free < pro < partner. The earlier personal/$2.50 tier was removed in favor of a single paid tier (its Stripe product/price/link are deactivated).invites.plan(migration 0065) is applied tousers.planwhen the invite is consumed at signup (password + social). NULL keeps today's legacy/unlimited behavior, so existing invites are unchanged and legacy cohorts still work by default. Admin invites UI gets a plan select.packages/worker/src/billing/, migration 0066) — raw-fetchStripe client with schema validation and a 10s request timeout; payment-link checkout;/account/billingsettings page;/account/billing/successlinksstripe_customer_idafter verifying a signedclient_reference_id;/account/billing/portalopens the Stripe billing portal;users.stripe_plancache refreshed on page view (60s staleness) and by an hourly cron lane (25 users/sweep).getUserPlanresolvesmax(users.plan, users.stripe_plan); a NULL manual plan (legacy/unlimited) is never downgraded by a subscription. Enforcement hot path stays a single D1 row read — Stripe is never called during entitlement checks.STRIPE_SECRET_KEY, billing degrades to manual plans (banner on the billing page; cron/portal/success no-op safely).Pricing (data-grounded)
Free $0 / Pro $5/mo, one paid tier. Benchmarks: IFTTT Pro $3.99, Make ~$10, Val Town Pro $10 legacy ($25 new, incl. $10 LLM credits), Zapier/n8n/Pipedream $20–29. Kody is BYO-agent (zero inference cost); worst-case per-user Cloudflare cost is ~$1–2/mo (DO duration), so $5 holds healthy margin while staying far from the $20 ChatGPT anchor. Live Stripe product/price/payment link are created and committed as production vars in
wrangler.jsonc.Security
Independent review flagged that a raw stable user id in
client_reference_idis forgeable (it's justSHA-256(email)); fixed by signing it withCOOKIE_SECRET(HMAC). The success endpoint also refuses to replace an established customer linkage and rejects customers already linked to another account (unique partial index enforced). Checkout session ids are redacted from error logs.Production rollout
STRIPE_SECRET_KEYsyncs via the deploy workflow (--set-from-env-optional); theSTRIPE_SECRET_KEYGitHub Actions secret must be added for billing to activate in production — until then everything runs in manual-plans mode.wrangler.jsonc(non-secret; coupling documented inline).Testing
npm run validategreen locally (format, lint, typecheck, unit, Playwright E2E, MCP E2E).freeplan.System recap — adds a new primitive (high risk)
Mode: recap · Base:
main@4f84d184· Head:76a4159eClassification: adds — new
billingprimitive (Stripe checkout/link/sync);entitlements,app-sessions, andd1-app-dbextended.Primitives touched
billingentitlementsfreeplan,personalremoved; effective plan = max(manual, stripe); NULL never downgradedapp-sessionsinvites.planto the new userd1-app-dbinvites.plan;users.stripe_customer_id(unique) /stripe_plan/stripe_plan_refreshed_atapp-ui/account/billingpage + admin invite plan selectemailjobs-cronstripe_plan_refreshlaneSystem map
Checkout flows from the billing UI through the new billing primitive into D1, and entitlements read the synced plan on every enforcement check.
Legend: green = composes (wiring only) · amber = extended by this PR · red = new primitive · gray = context (unchanged, included only when an edge crosses it).
Before / after
Invariants
Per-user isolation: checkout linking binds
client_reference_id= HMAC(stable user id, COOKIE_SECRET); a customer can never link to two accounts (unique partial index + pre-check) and an established linkage is never replaced by a later session.Summary by CodeRabbit