# 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:///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 ```