Add Stripe billing (and test it without Stripe)
Start state: nothing. End state: a three-tier pricing page with a working upgrade flow — instantly, with no Stripe account — plus the exact wiring to flip on real Stripe Checkout when you are ready. The billing feature is deliberately dual-mode: a local mock path for development and a Stripe path gated purely on environment secrets.
1. Scaffold with billing
pnpm new saasly --title "Saasly" --type webapp --features billing
pnpm install
cd apps/saasly && npx convex dev --once && pnpm exec tsc --noEmit
2. Plans and PLAN_META
The plan catalog lives in the shell (packages/shell/src/shell/ui/billing.tsx) as PLAN_META — three tiers with label, price, and feature bullets:
export const PLAN_META: Record<PlanTier, { label: string; price: string; features: string[] }> = {
free: { label: "Free", price: "$0", features: ["1 workspace", "Local data", "Community support"] },
pro: { label: "Pro", price: "$12", features: ["Unlimited workspaces", "Agent API keys", "Priority support"] },
team: { label: "Team", price: "$49", features: ["Everything in Pro", "SSO", "Audit log", "Shared workspaces"] },
};
The same file exports planAllows(current, required) for gating and an UpgradeNudge component to render in place of a plan-locked feature. The Billing page itself is shell-owned (billingFeature) and renders a PlanCard per tier.
3. The upgrade flow
Clicking Upgrade calls the startCheckout action in convex/billing.ts. Two outcomes:
const res = await startCheckout({ plan });
if (res.url) window.location.href = res.url; // real Stripe Checkout
else success(`Upgraded to ${PLAN_META[plan].label}`); // local mock
Without Stripe secrets, the action takes the mock path and applies the plan immediately via the internal setPlan mutation — so you can develop and demo the entire upgrade/cancel/gating loop with zero keys. With secrets set, it returns a Checkout URL instead and the browser redirects to Stripe.
setPlan is an internalMutation on purpose: it is reachable only through the trusted startCheckout action or a Stripe webhook — never from a client. Exposing it as a public mutation would let any user self-upgrade for free.
4. Where the Stripe keys go (Convex)
npx convex env set STRIPE_SECRET_KEY sk_live_… --prod
npx convex env set STRIPE_PRICE_PRO price_… --prod
npx convex env set STRIPE_PRICE_TEAM price_… --prod
Keys live on the Convex deployment, never in the frontend bundle. In real mode a Stripe webhook should call the internal setPlan to finalize the plan after checkout.session.completed — see the note in convex/http.ts.
5. The Supabase variant
On --db supabase apps, billing is two Edge Functions plus a set_plan() SQL RPC for local mode:
- stripe-checkout — creates the Checkout Session (Stripe's REST API via fetch, dependency-free); without STRIPE_SECRET_KEY it returns { mode: "local" } and the app falls back to the synchronous set_plan RPC
- stripe-webhook — deployed with --no-verify-jwt; verifies Stripe's t=<ts>,v1=<hmac> signature with WebCrypto HMAC-SHA256, then upserts subscriptions with the service role (the trusted path RLS does not apply to)
supabase functions deploy stripe-checkout
supabase functions deploy stripe-webhook --no-verify-jwt
supabase secrets set STRIPE_SECRET_KEY=sk_… STRIPE_PRICE_PRO=price_… STRIPE_PRICE_TEAM=price_… STRIPE_WEBHOOK_SECRET=whsec_…
Then add the webhook endpoint in the Stripe dashboard (https://<project>.functions.supabase.co/stripe-webhook, event checkout.session.completed).
6. Test the full loop — no Stripe account
pnpm dev
Sign up, open Billing, click Upgrade to Pro. Expected: an instant "Upgraded to Pro" toast, the Pro card marked Current plan, and (on Convex) a billing.upgraded activity row. Click Cancel to drop back to Free. Any planAllows-gated UI flips accordingly — the whole monetization story is testable before the first real key exists.
What you built
- A three-tier pricing page with upgrade, downgrade, and cancel, working end-to-end locally
- A Stripe integration that turns on by setting secrets — no code changes between mock and real mode
- The security shape to keep: internal setPlan, server-held keys, HMAC-verified webhooks