# Stripe Billing Go-Live Runbook

> Turning on paid subscriptions (#251). The billing code is merged (#390 +
> #392) but **dormant until configured** — `isBillingEnabled()` is false
> with no `STRIPE_SECRET_KEY`, so the UI hides billing actions and the
> webhook route returns 503. This runbook is the wiring + verification to
> actually charge cards.

## 🎯 What's already built

- **Service**: `packages/trpc/src/services/billing/stripe.ts` — hosted
  Checkout + Customer Portal, Stripe customer per org, price resolution
  from stable `lookup_key`s. No card data touches our servers.
- **Webhook**: `packages/trpc/src/services/billing/stripeWebhook.ts` +
  route `apps/api/src/routes/webhooks.ts` → `POST /api/webhooks/stripe`.
  Signature-verified; reconciles `organizations.tier` from subscription
  state.
- **tRPC**: `organizations.createCheckoutSession` /
  `createPortalSession` (owner/admin only).
- **Web**: the org **Usage** page shows *Manage plan* (portal) when
  subscribed, else a monthly/annual toggle + *Upgrade* buttons.

`organizations.tier` (`free` / `hobby` / `team` / `pro`) stays the gating
source of truth; Stripe reconciles it.

## ✅ Pre-launch checklist

### 1. Create products + prices in **live** Stripe

Each paid tier needs a price per interval, tagged with a stable
`lookup_key`. The code resolves prices **only** by these keys (never raw
`price_…` ids), so they must match exactly:

| Tier  | Monthly key          | Annual key          |
|-------|----------------------|---------------------|
| hobby | `izri_hobby_monthly` | `izri_hobby_annual` |
| team  | `izri_team_monthly`  | `izri_team_annual`  |
| pro   | `izri_pro_monthly`   | `izri_pro_annual`   |

> Set the **Lookup key** field on each price (Dashboard → Product → price
> → Advanced, or `--lookup-key` via the API). Create the same six in
> *test* mode first for verification. Rotating a price later — archive the
> old, create a new one with the **same** lookup_key — needs no code or
> env change.

### 2. Activate the Customer Portal

Dashboard → **Settings → Billing → Customer portal**: enable it and allow
plan switching + cancellation. `createPortalSession` fails until the
portal is configured (separately in test and live).

### 3. Set environment variables (prod / Railway)

| Variable                | Value                                            |
|-------------------------|--------------------------------------------------|
| `STRIPE_SECRET_KEY`     | live secret key (`sk_live_…`)                    |
| `STRIPE_WEBHOOK_SECRET` | signing secret of the prod webhook (step 4)      |
| `ENFORCE_RUN_QUOTA`     | **`true`** — see warning below                   |

Also confirm `APP_URL` is the real public web origin: Checkout redirects
to `${APP_URL}/organizations/<slug>/usage?checkout=success|cancelled` and
the portal returns to the same Usage page. A wrong `APP_URL` sends paying
users to a dead link after payment.

> ⚠️ **`ENFORCE_RUN_QUOTA=true` is not optional for a paid launch.** Left
> unset/`false`, the run-quota gate only meters and warns — the free tier
> is effectively uncapped, so paid tiers buy nothing. Flip it on at launch.

### 4. Register the webhook endpoint

Dashboard → **Developers → Webhooks → Add endpoint**:

- **URL**: `https://<api-host>/api/webhooks/stripe`
- **Events**:
  - `checkout.session.completed`
  - `customer.subscription.created`
  - `customer.subscription.updated`
  - `customer.subscription.deleted`

Copy the endpoint's **signing secret** into `STRIPE_WEBHOOK_SECRET`
(step 3) and redeploy the API.

### 5. Apply the database migrations

The Stripe columns ship in two migrations — apply both in prod:

- `0021_tranquil_jackpot.sql` — `stripe_customer_id`,
  `stripe_subscription_id`, `stripe_subscription_status` + index
- `0022_typical_tomorrow_man.sql` — `stripe_cancel_at_period_end`

```bash
pnpm db:migrate   # runs outstanding Drizzle migrations
```

## 🧪 Verify against test Stripe first

Do a full dry run in **test mode** before pointing prod at live keys.

```bash
# Forward test webhooks to the local API (port 4000)
stripe listen --api-key sk_test_… \
  --forward-to localhost:4000/api/webhooks/stripe
# → prints the whsec_… to use as STRIPE_WEBHOOK_SECRET locally
```

Walk the lifecycle and assert `organizations.tier` after each step:

1. **Upgrade** — from the Usage page, Upgrade to a tier → complete
   Checkout with test card `4242 4242 4242 4242`. Expect
   `checkout.session.completed` + `customer.subscription.created` →
   `tier` flips to the purchased tier.
2. **Manage** — *Manage plan* opens the portal; switch interval/tier →
   `customer.subscription.updated` → `tier` tracks the new price.
3. **Cancel** — cancel in the portal → on period end
   `customer.subscription.deleted` → `tier` returns to `free`.
4. **Replay/idempotency** — `stripe events resend <evt_id>` lands the
   same row state (the webhook re-fetches the subscription fresh, so
   reconciliation is order- and replay-independent).

`stripe trigger customer.subscription.deleted` etc. can drive these
without real card flows.

## 🔎 Behaviours worth knowing (so prod surprises don't look like bugs)

- **`past_due` keeps access.** `isActiveSubscriptionStatus` counts
  `active`, `trialing`, **and `past_due`** as active — a card being
  retried doesn't instantly lose the paid tier. Dunning is left to
  Stripe; access drops only on terminal cancellation.
- **Unknown `lookup_key` ≠ downgrade.** If an *active* subscription's
  price has no recognised lookup_key (a misconfigured price), the webhook
  **holds the current tier** and logs
  `active subscription has unrecognised price lookup_key` at error level
  rather than silently dropping the org to `free`. If you see that log,
  fix the price's lookup_key in the dashboard (step 1). Watch for it
  after any price change.
- **Webhook failures retry.** Any handler error (incl. transient DB
  blips) returns non-2xx, so Stripe retries — expected. A persistent 400
  means a bad signature (wrong `STRIPE_WEBHOOK_SECRET`) or unconfigured
  billing (503).

## 🔁 Rollback

Billing is feature-flagged by config: **unset `STRIPE_SECRET_KEY`** (and
redeploy) to make the module dormant again — UI hides billing, the
webhook 503s. Existing `organizations.tier` values are untouched, so
already-provisioned orgs keep their tier until you reconcile manually.
The migrations are additive (nullable columns) and need no rollback.
