# Stripe Billing Core (3A) — Design Spec

**Date:** 2026-06-12
**Status:** Approved (design) — ready for implementation plan
**Sub-project:** 3A of 4 (i18n ✅ → onboarding wizard ✅ → **Stripe billing 3A core** → 3B quotas/metering → 3C packs/pricing page → agency whitelisting)
**Pricing source of truth:** `~/Documents/seo insight/docs/Pricing.md` (2026-06-12) — 5 plans, –20 % annuel, sièges 19 $, trial Pro 14 j sans CB, **pas de plan free app**.

## Goal

Wire real subscriptions: **@better-auth/stripe plugin**, org-scoped billing, hosted Stripe Checkout + Customer Portal, an **app-level 14-day trial** (no card, no Stripe interaction until purchase), a **blocking screen** when neither trial nor subscription is active, a Settings→Billing page, and the wizard's Forfait step rewritten to the real 5-plan grid. Quota *enforcement* (3B) and credit packs / public pricing page (3C) are explicitly deferred.

## Decisions (locked)

- **Plugin:** `@better-auth/stripe`, versions alignées (bump `better-auth` 1.6.14 → **1.6.17** + `@better-auth/stripe@1.6.17` + `stripe` SDK). Hosted Checkout + Customer Portal (no embedded Elements).
- **Org-scoped:** subscriptions attach to the org (`referenceId = organization.id`, `customerType: "organization"`, plugin `organization: { enabled: true }`). `authorizeReference` allows only org **owner/admin**.
- **Plans (plugin config, lookup keys):** solo / starter / pro / agency, monthly + annual (`annualDiscountLookupKey`), `limits` carried in config for 3B. Enterprise = contact (no Stripe plan). Seat price (`seatPriceId`, 19 $/mo, auto-synced to member count) configured for pro/agency.
- **Prices created via Stripe CLI in TEST mode** (account already logged in). NEVER pass `--live`. Production prices = runbook item (re-run script with live key).
- **Trial = app-level:** `organization.trialEndsAt`, stamped by `createOrgForUser` **only for the user's first org** (one trial per user; disposable-email guard already exists). No Stripe `freeTrial` config. Trial duration 14 days.
- **Access gate:** `getOrgBillingStatus(orgId)` fresh-read (same anti-cookieCache pattern as `hasPendingOnboarding`) → `'trialing' | 'active' | 'expired'`. `(app)/layout.tsx` redirects `expired` → `/billing-locked` (route OUTSIDE `(app)` — no loop). Data stays intact; MCP keys untouched (3B owns MCP quotas).
- **Price IDs:** resolved by **lookup keys** (`solo_monthly`, `solo_annual`, …, `seat_monthly`, `seat_annual`) — only 3 env vars total (`STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY` already present; add `STRIPE_WEBHOOK_SECRET`).
- **Currency:** USD primary with `currency_options` EUR at nominal parity (49 $ = 49 €), per pricing doc.

## Out of scope (explicit)

- Quota enforcement + trial quota halving (750 kw / 50k MCP) + monthly MCP metering → **3B**.
- Credit packs (one-off payments — plugin doesn't support `mode: "payment"`; raw Checkout) + public pricing page + extra-seat purchase UX → **3C**.
- Enterprise (sur devis) flows; agency whitelisting (sub-project 4); dunning/emails.

## Plan grid (from Pricing.md — limits stored in plugin config for 3B)

| lookup keys | monthly | annual (total/yr) | keywords | projects | seats incl. | integrations | backlink rows | MCP/mo |
|---|---|---|---|---|---|---|---|---|
| `solo_monthly` / `solo_annual` | $9.99 | $95.88 | 100 | 1 | 1 | 1 | 10 000 | 1 000 |
| `starter_monthly` / `starter_annual` | $49 | $468 | 500 | 5 | 1 | 2 | 100 000 | 10 000 |
| `pro_monthly` / `pro_annual` | $129 | $1 188 | 1 500 | 15 | 3 | 5 | 500 000 | 100 000 |
| `agency_monthly` / `agency_annual` | $299 | $2 868 | 5 000 | 40 | 10 | unlimited | 2 000 000 | 500 000 |
| `seat_monthly` / `seat_annual` | $19 | $182.40 | — | — | extra seat (pro/agency) | — | — | — |

All prices also carry `currency_options[eur]` at the same nominal amount (cents identical).

## Architecture

### 1. Stripe bootstrap (test mode)
`scripts/stripe-bootstrap.ts` — bun script using the `stripe` SDK with `STRIPE_SECRET_KEY` (test key from .env.local). Idempotent: for each plan, find price by lookup key (`stripe.prices.list({ lookup_keys: [...] })`); create product + prices only if missing. Creates 5 products (Solo, Starter, Pro, Agency, Seat) and the 10 recurring prices above with `lookup_key`, `currency: "usd"`, `currency_options: { eur: { unit_amount: <same cents> } }`. Annual prices: `recurring: { interval: "year" }` with the yearly total. Run with `bun --env-file=.env.local run scripts/stripe-bootstrap.ts`. Output: a table of lookup key → price id.

### 2. Plugin wiring
- `sfw bun add @better-auth/stripe stripe` + bump `better-auth` to the matching exact version (1.6.17). Full gate after the bump.
- `src/env.ts`: add `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET` (optional in dev) + derived `stripeEnabled` flag (all three present), following the `emailEnabled` pattern (fail-closed semantics on prod).
- `src/lib/billing/plans.ts`: single source of truth — plan names, lookup keys, limits (table above), display prices, seat price. Consumed by the auth plugin config AND the UI (billing page, wizard, blocking page). Pure data + tiny helpers (`getPlan(name)`), unit-testable.
- `src/lib/auth.ts`: `stripe({ stripeClient: new Stripe(env.STRIPE_SECRET_KEY), stripeWebhookSecret, createCustomerOnSignUp: false, subscription: { enabled: true, plans, authorizeReference, requireEmailVerification: env.emailEnabled }, organization: { enabled: true } })` — plugin added ONLY when `env.stripeEnabled` (app must boot without Stripe in dev). `authorizeReference`: query `member` for (referenceId, userId) role in `owner|admin`.
- `src/lib/auth-client.ts`: add `stripeClient({ subscription: true })`.
- Webhook endpoint is mounted by the plugin at `/api/auth/stripe/webhook`; dev runbook: `stripe listen --forward-to localhost:3000/api/auth/stripe/webhook` → copy whsec into .env.local (user does env edits).

### 3. Schema & migration 0031
- Run `bunx @better-auth/cli generate` to emit the plugin schema; port into our Drizzle files (auth-schema.ts): `subscription` table (id, plan, referenceId, stripeCustomerId, stripeSubscriptionId, status, periodStart, periodEnd, cancelAtPeriodEnd, seats, trialStart, trialEnd, …), `stripeCustomerId` on `user` AND `organization`.
- Add `trialEndsAt: timestamp("trial_ends_at")` on `organization` (app-level trial).
- Migration `0031` — **`when` bumped to `1781320000000`** (current max 1781310000000). `db:migrate` + `db:migrate:test`.

### 4. Trial bootstrap
`createOrgForUser` (src/lib/auth/org-bootstrap.ts): when creating the org, stamp `trialEndsAt = now + 14 days` **iff the user owns no other org** (count `member` rows with role owner for this user). Subsequent orgs get `trialEndsAt = null` → immediately `expired` unless subscribed. **Backfill:** existing orgs (dev/test/prod) get a trial stamp via the migration or a one-off script so current users aren't locked out — decision: the 0031 migration sets `trial_ends_at = now() + interval '14 days'` for ALL existing orgs (simple, generous, one-time).

### 5. Billing status gate
`src/lib/billing/status.ts`:
- `computeBillingStatus({ trialEndsAt, subscriptionStatus }, now): 'trialing' | 'active' | 'expired'` — pure: active if subscription status ∈ {active, trialing, past_due*}; else trialing if `trialEndsAt > now`; else expired. (*past_due counts as active in 3A; dunning later.)
- `getOrgBillingStatus(orgId)` — fresh reads: org.trialEndsAt + latest subscription row for referenceId=orgId; returns the computed status. server-only.
- `(app)/layout.tsx`: after the onboarding gate, `if (billingStatus === 'expired') redirect('/billing-locked')`. Super-admins (`isCurrentUserSuperAdmin()`) bypass.
- `/billing-locked` (outside `(app)`): full-screen page — "essai terminé" headline, 4 plan cards (from `plans.ts`) with monthly/annual toggle, each → `authClient.subscription.upgrade({ plan, annual, referenceId: activeOrgId, customerType: "organization", successUrl: "/keywords/reservoir", cancelUrl: "/billing-locked" })`; Enterprise card → mailto/contact. Shows who to nag if the viewer isn't owner/admin (members can't pay).

### 6. Settings → Billing
`src/app/(app)/settings/billing/page.tsx` (+ entry in settings-shell + sidebar Settings group): current plan + status badge, trial countdown (days left) when trialing, monthly/annual toggle, plan cards with upgrade/downgrade via `subscription.upgrade` (passing `subscriptionId` when one exists), "Gérer le paiement" → `subscription.billingPortal({ referenceId, returnUrl })`, cancel (→ Portal flow) / restore. Owner/admin only (others see read-only state). `nav-user`: "Billing" item links here; "Upgrade to Pro" links here too (or to /billing-locked plan grid when expired).

### 7. Wizard Forfait step rewrite
`plan-step.tsx`: replace the 3 scaffold cards with the real grid — Solo/Starter/Pro/Agency (+ Enterprise "contact" card). Default highlighted choice = **"Essai Pro 14 jours — sans carte"** (just continues; trial already stamped at signup). Selecting a paid plan stores the intent; after `completeOnboarding()`, redirect to Checkout for that plan instead of the app (successUrl = app, cancelUrl = app — they still have their trial).

### 8. i18n
New `Billing` namespace in `messages/{en,fr,es,de}.json` (plan names/descriptions, status labels, trial countdown, blocking page copy, portal/upgrade CTAs) + extended `Onboarding` keys for the new plan step. Parity test already enforces all namespaces.

## Testing (gate: `bun run test:all`)
1. `computeBillingStatus` (pure): subscription active → active; no sub + future trialEndsAt → trialing; no sub + past/null trialEndsAt → expired; cancelAtPeriodEnd still active until periodEnd.
2. `plans.ts` invariants: 4 plans, lookup keys unique, annual = monthly×12×0.8 (cents, ±1), limits match the grid.
3. Trial bootstrap: first org gets trialEndsAt ≈ now+14d; second org for same user gets null (DB test, org-bootstrap pattern).
4. `authorizeReference`: owner/admin true, staff/non-member false (DB test).
5. Catalog parity (Billing namespace) stays green.

## Live verification (controller, d3k + Stripe test mode)
- Trial org → app accessible; set `trial_ends_at` in the past via psql → `/billing-locked` appears; data intact.
- From the blocking page: Checkout (test card 4242 4242 4242 4242) → webhook (via `stripe listen`) → subscription row active → app unlocked.
- Settings→Billing shows the active plan; Portal opens.

## Risks / notes
- **better-auth bump 1.6.14→1.6.17**: patch-level; full gate + prod build must pass before merge. Pin exact versions of better-auth and @better-auth/stripe together.
- **App must boot without Stripe env** (dev contributors, CI): plugin + UI degrade behind `env.stripeEnabled`; billing gate treats missing Stripe as "trial rules only".
- **Existing-org backfill** prevents locking out current users the moment the gate ships.
- **Multi-org re-trial loophole** (user creates 2nd org): closed by first-org-only stamping; orgs without trial/sub are expired from birth — acceptable because creating extra orgs is itself a paid-tier behavior (full multi-org rules in 3B).
- **Webhook secret in dev** comes from `stripe listen` (user pastes into .env.local — agent cannot edit env files).
