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