Files

136 lines
6.9 KiB
Markdown
Raw Permalink Normal View History

# Stripe billing setup (Descrybe v2)
Self-serve Checkout for **Starter / Plus / Growth / Business / Scale** (subscriptions), **AI credit packs** (one-time payment), Customer Portal for subscription management, and signed webhooks that assign plans / grant credits.
Enterprise stays sales-led (no self-serve price).
## Plans vs credit packs
| Kind | Stripe mode | Checkout body | Webhook effect |
|---|---|---|---|
| Plan subscription | `subscription` | `{ "plan": "starter\|plus\|growth\|business\|scale", "term": "monthly\|yearly" }` | Assign plan + monthly credit grant |
| Credit pack | `payment` | `{ "pack": "small\|medium\|large\|xl" }` | Add pack credits to the company wallet (no plan change) |
Monthly plan credits are sized with a **tiered cover %** of the SKU cap at ~2 credits per AI enhance (`PlanAICoverPercent` / `MonthlyCreditsForPlan`):
| Plan | Cover % | Monthly credits |
|---|---:|---:|
| Starter / Plus | 25% | 250 / 1,250 |
| Growth / Business | 50% | 10,000 / 40,000 |
| Scale | 25% | 50,000 (bounds absolute COGS on large catalogs) |
| Enterprise | sales / BYOK | 1,000,000 managed wallet |
Packs cover the remaining catalog, fixes, and tests without upgrading SKU caps.
Pack ladder (marketing USD; live Prices live in Stripe / admin settings):
| Pack ID | Credits | List |
|---|---:|---:|
| `small` | 500 | $19 |
| `medium` | 2,500 | $69 |
| `large` | 15,000 | $249 |
| `xl` | 50,000 | $699 |
## Configuration (preferred: admin dashboard)
Platform admin → **`/admin/settings`** (`GET/PUT /api/admin/settings`). Stripe lives under `values.*` (secrets masked on GET; omit a secret on PUT to keep the existing value):
| Settings key | Purpose |
|---|---|
| `stripe.secret_key` | Secret key (`sk_test_…` / `sk_live_…`). Empty → mock-oriented behavior. |
| `stripe.webhook_secret` | Signing secret (`whsec_…`). Needed for live webhooks. |
| `stripe.mock` | `true` forces mock even if a secret key is set (local QA). |
| `stripe.price.starter|plus|growth|business|scale.monthly|yearly` | Subscription Price IDs |
| `stripe.price.pack.small|medium|large|xl` | One-time pack Price IDs |
`WEB_ORIGIN` remains a **boot** env var (Checkout success/cancel + Portal return URLs).
### Optional env fallback
Process env (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_MOCK`, `STRIPE_PRICE_*`) still works as fallback when admin values are unset. Prefer `/admin/settings` for new setups. Production does **not** require Stripe secrets at API boot.
Pack env keys: `STRIPE_PRICE_PACK_SMALL`, `STRIPE_PRICE_PACK_MEDIUM`, `STRIPE_PRICE_PACK_LARGE`, `STRIPE_PRICE_PACK_XL`.
## Mock / dry-run mode
Set `stripe.mock=true` in `/admin/settings` (or `STRIPE_MOCK=true` in env) for local free Checkout (and unsigned webhooks when no webhook secret). Empty secret key alone reports mock-configured status but does **not** assign plans or grant pack credits.
When mock is on:
- `POST /api/billing/checkout` with a **plan** immediately assigns the plan, grants monthly credits, and redirects to `/billing?checkout=success&mock=1`.
- `POST /api/billing/checkout` with a **pack** immediately adds credits and redirects with `pack` + `credits` query params.
- `POST /api/billing/portal` returns a local `/billing?portal=mock` link.
- `POST /api/webhooks/stripe` accepts unsigned JSON only when no webhook secret is set (local scripts). If a webhook secret is present, signatures are always verified (even in mock). Events remain idempotent by `event.id`.
Local demo works with no Stripe account. Admin settings apply without reinventing boot secrets; restart only if you changed process env.
## Stripe Dashboard setup
1. Create a Product per **plan** (Starter, Plus, Growth, Business, Scale) with monthly + yearly recurring Prices.
2. Create a Product (or one Product with four Prices) for **AI credit packs** — Prices must be **one-time** (not recurring).
3. Paste each Price ID into **`/admin/settings`** (`stripe.price.*`) — or optional `STRIPE_PRICE_*` env fallback.
4. Enable Customer Portal (Settings → Billing → Customer portal): allow cancel / update payment method.
5. Webhook endpoint (API): `https://<your-api-host>/api/webhooks/stripe`
- Events: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`
6. Local forwarding:
```bash
stripe listen --forward-to localhost:8080/api/webhooks/stripe
```
Paste the printed `whsec_…` into `/admin/settings``stripe.webhook_secret` (or optional `STRIPE_WEBHOOK_SECRET` env).
### Sync public plan meters to Postgres
After changing default credits / SKU caps in code:
```bash
cd apps/api && go run ./cmd/sync-plans
```
## API surface
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/api/billing/stripe` | session + company | `{ configured, mock, has_customer, has_subscription }` |
| GET | `/api/billing/credit-packs` | session + company | `{ packs: [...] }` catalog |
| POST | `/api/billing/checkout` | session + company admin | plan **or** pack body → `{ url, mock? }` |
| POST | `/api/billing/portal` | session + company admin | → `{ url, mock? }` |
| POST | `/api/webhooks/stripe` | Stripe signature (or mock) | Idempotent via `stripe_webhook_events` |
Checkout metadata:
- Plans: `kind=plan`, `company_id`, `plan`, `term`
- Packs: `kind=credit_pack`, `company_id`, `pack`, `credits`
Webhooks resolve the company from metadata, `client_reference_id`, or `stripe_customer_id` — never from a client-supplied company without session scope on authenticated routes. Pack completion grants credits from the catalog amount for the pack id (ignores tampered `credits` metadata when the pack id is known).
## Migration
```bash
# from repo root, with DATABASE_URL set
pwsh -File scripts/migrate.ps1
# applies 016_stripe_billing.sql (companies.stripe_customer_id, company_plans stripe fields, stripe_webhook_events)
```
## Local status (dev machine)
- Migration applied locally: goose version 16 (`016_stripe_billing.sql`) creates `stripe_webhook_events` plus `companies.stripe_customer_id` / `company_plans.stripe_*`.
- For mock Checkout smoke: set `stripe.mock=true` in **`/admin/settings`** (or `STRIPE_MOCK=true` in root `.env` as fallback). Restart API only if you changed process env. DB migrate alone does not require restart.
- Smoke plan: login `demo@descrybe.local` / `DemoPass123!`, then `POST /api/billing/checkout` with `{"plan":"starter","term":"monthly"}` returns `mock:true`, `applied:true`.
- Smoke pack: `POST /api/billing/checkout` with `{"pack":"small"}` returns `mock:true`, `applied:true` and adds 500 credits.
## UI
- `/plans` — Upgrade buttons call Checkout for self-serve plans.
- `/billing` — Upgrade shortcuts + **Buy AI credit packs** + Manage subscription (Customer Portal).
- Marketing `/pricing` CTAs stay register / sales; signed-in upgrades use `/plans` or `/billing`.
## Verify
```bash
cd apps/api && go test ./internal/billing/ -count=1
cd apps/api && go build ./...
cd apps/web && npm run check
```