136 lines
6.9 KiB
Markdown
136 lines
6.9 KiB
Markdown
# 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
|
||
|
|
```
|