# Billing & Packaging

CoreReflex bills natively — no Stripe SDK, just signed REST calls and an
HMAC-verified webhook. The whole subsystem is **inert until configured**, so the
app runs end to end with billing off (the default for local dev and any
deployment that hasn't set Stripe keys). Source: [`src/billing.js`](../../src/billing.js).

**On this page**

- [Plans & entitlements](#plans--entitlements)
- [The effective plan: owner override & view-as-tier](#the-effective-plan-owner-override--view-as-tier)
- [Credits & metering](#credits--metering) — incl. [owner/admin never metered](#owner--app-admins-are-never-metered)
- [Consumption confirmation (migration 038)](#consumption-confirmation-migration-038)
- [Where each reason is metered](#where-each-reason-is-metered)
- [Metering invariants](#metering-invariants)
- [Idempotent grants (migration 011)](#idempotent-grants-migration-011)
- [Webhook exactly-once (migration 012)](#webhook-exactly-once-migration-012)
- [Storage packaging (migration 026)](#storage-packaging-migration-026)
- [One-time add-ons: +1,000 credits / +50 GiB](#one-time-add-ons-1000-credits--50-gib)
- [Stripe integration (native REST)](#stripe-integration-native-rest)
- [Reading entitlements](#reading-entitlements)
- [The inert flag](#the-inert-flag)
- [API surface](#api-surface)
- [Gates summary](#gates-summary)
- [Tests](#tests)

## Plans & entitlements

`PLAN_FEATURES` defines three tiers. Everything is scoped to `user.workspace`.

| Tier | Watermark | Exports / mo | Private generation | Client ops | Included credits | Included storage |
|------|-----------|--------------|--------------------|------------|------------------|------------------|
| **Free** | yes | 5 | no | off | 0 | 1 GiB |
| **Pro** | removed | 100 | no | on | 1,000 | 25 GiB |
| **Enterprise** | removed | unlimited | yes | on | 10,000 | 500 GiB |

`planAtLeast(plan, 'pro')` is the comparator used across the codebase to gate
Pro features (e.g. `clientOpsEnabled` in [`src/crm.js`](../../src/crm.js)). The
ranking is `free < pro < enterprise`. Storage tiers live in
`PLAN_STORAGE_BYTES` (see [Storage packaging](#storage-packaging-migration-026)).

> The export allowances are currently **plan policy**, not a hard wall —
> `maxExportsPerMonth` has no enforcing call site yet. Watermark removal
> (`src/render-worker.js` flips `manifest.watermark` off for Pro+ plans),
> private-mode gating, and client-ops gating are enforced today.

## The effective plan: owner override & view-as-tier

Gating never reads the billed plan directly — it reads the **effective plan**,
resolved once per session in [`src/permissions.js`](../../src/permissions.js):

```js
export function resolveEffectivePlan({ email, basePlan = 'free', previewPlan = null } = {}) {
  if (isPlatformOwner(email)) {
    if (previewPlan && PLAN_TIERS.includes(previewPlan)) return previewPlan;
    return TOP_PLAN; // 'enterprise'
  }
  return basePlan || 'free';
}
```

- **Owner god-mode entitlement.** `isPlatformOwner(email)` compares against the
  single `mainAdminEmail` constant (one place to change the owner — no second
  literal anywhere). The platform owner always resolves to the top tier, so
  **every Pro/Enterprise gate opens on the owner's own workspace** without a
  paid subscription. Everyone else gets their real (billed) plan.
- **Resolved at the session seam, not per gate.** `publicUser` in
  [`src/session.js`](../../src/session.js) rewrites `user.workspace.plan` to the
  effective plan when building the session user. Every downstream gate —
  `planAtLeast`, `clientOpsEnabled`, `brandStudioEnabled`, the watermark path —
  reads that one field, so the override covers editor + dashboard + all
  server-side gates without touching each call site.
- `permissions.js` deliberately has **no import into billing or the DB layer**
  (`TOP_PLAN` is duplicated there, not imported from `billing.js`) so
  entitlement resolution stays cycle-free.

### View-as-tier (`POST /api/admin/preview-plan`)

The override would make the owner blind to what Free/Pro users actually see, so
the God Console pairs it with a **view-as-tier** preview
([`src/admin-api.js`](../../src/admin-api.js), UI in `public/dashboard.html`):

1. `POST /api/admin/preview-plan` with `{ "plan": "free" | "pro" | "enterprise" }`
   (or `"reset"` / `null` to clear). Requires the `god_mode` admin privilege
   **and** `isPlatformOwner` — the preview is owner-only, since only the owner
   is top-tier to begin with. It is also rejected during an impersonation
   session (`requireAdmin` demands you return to your own session first).
2. `setPlanPreview` **re-mints the owner's session** carrying a
   `{ previewPlan }` claim, revokes the old `auth_sessions` row, and records a
   `preview.plan.changed` admin audit event. The response returns the new
   `token`, the `previewPlan`, and the resolved effective `plan`.
3. On every subsequent session read, `publicUser` honors the claim — but only
   for the owner (`isPlatformOwner` is checked again before the claim is read,
   so a preview claim on anyone else's session is inert). It also surfaces
   `planPreview` on the public user so any surface can show a
   "previewing Free" banner with a one-click restore.

The preview is a **view-only session claim, never persisted** to `accounts.plan`.
It changes gating only; the credit ledger and balance stay real.

### Interplay with entitlements

`getEntitlements` prefers the session's already-resolved
`user.workspace.plan` (effective) over the raw billed plan from the DB, so the
Plan page and the credit floor **match what the gates enforce**. Real customers
see no difference — for them effective === billed.

## Credits & metering

Genuinely expensive AI work draws down a prepaid **credit ledger** (migration
[`006`](../../migrations/006_billing.sql), table `credit_ledger`; balance mirrored
on `accounts.credits`).

- `USAGE_COSTS` — integer credit cost per metered operation. Costs are integers
  so the ledger stays exact. **Self-hosted (open-model) lanes bill their own,
  far cheaper `*-own` / `corereflex*` reasons** — the operation, not the vendor,
  decides which reason is charged (resolved before metering; see
  [engine-aware metering](#where-each-reason-is-metered)):

  | Reason | Credits | Operation | Lane |
  |--------|--------:|-----------|------|
  | `usage:persona` | 120 | A talking-head / persona render | vendor |
  | `usage:veo` | 100 | A Veo video shot | vendor |
  | `usage:kling` | 60 | A Kling video shot | vendor |
  | `usage:seedance` | 45 | A Seedance video shot | vendor |
  | `usage:wan` | 25 | A Wan video shot | vendor |
  | `usage:corereflex-lf` | 20 | A self-hosted **long-form** video shot | self-hosted |
  | `usage:hailuo` | 20 | A Hailuo video shot | vendor |
  | `usage:lyria` | 20 | A Lyria music score | vendor |
  | `usage:corereflex` | 15 | A self-hosted (short-form) video shot | self-hosted |
  | `usage:render` | 10 | A native render / short / auto-clip | own |
  | `usage:upscale` | 8 | An own-tech video upscale | own |
  | `usage:imagen-edit` | 6 | An AI mask edit (fill / erase / expand) | vendor |
  | `usage:imagen` | 5 | An Imagen still | vendor |
  | `usage:voice` | 5 | A vendor (Chirp) TTS / voice-design synthesis | vendor |
  | `usage:audio-fx` | 5 | A per-clip audio-FX (DSP) pass | own |
  | `usage:stock-gen` | 5 | A generated stock asset | — |
  | `usage:remove-bg` | 4 | A background removal | own |
  | `usage:vectorize` | 4 | A raster → SVG trace | own |
  | `usage:content` | 3 | A vendor text/vision AI call (write, captions, highlights, …) | vendor |
  | `usage:music-own` | 3 | A self-hosted music score | self-hosted |
  | `usage:image-own` | 2 | A self-hosted still | self-hosted |
  | `usage:publish` | 2 | A social publish/schedule relay | own |
  | `usage:lyrics` | 2 | AI songwriting (lyrics + style tags) | vendor |
  | `usage:voice-own` | 1 | A self-hosted TTS synthesis (e.g. `model:"kokoro"`) | self-hosted |
  | `usage:content-own` | 1 | A self-hosted text/vision call | self-hosted |
  | `usage:stock` | 1 | A stock-library fetch | — |

  > `usage:kling` and the other `fal`-routed video reasons feed the workflow
  > budget estimator ([`src/budget.js`](../../src/budget.js)) and the admin cost
  > model ([`src/cost-model.js`](../../src/cost-model.js)). `POST /api/generate`
  > meters **the reason of the provider that actually runs the spec**
  > (`creditReasonForProvider` in [`src/providers.js`](../../src/providers.js)) —
  > a flat `usage:veo` would over-bill every non-Veo job.

- `meterUsage(user, reason, options)` — the gate. **No-op unless billing is
  enabled** and the reason has a non-zero cost. When enabled it performs an
  **atomic gate-and-spend** in a single statement:

  ```sql
  update accounts a set credits = a.credits - $2
  from workspaces w where w.account_id = a.id and w.id = $1 and a.credits >= $2
  returning a.id as account_id, a.credits as balance
  ```

  The `and a.credits >= $2` guard is load-bearing: a check-then-act (read the
  balance, then decrement) lets two concurrent generations both pass the check
  and **double-spend** the same credits. Decrementing only when the row still
  covers the cost makes the spend race-safe. **No row back ⇒ insufficient
  credits ⇒ it throws `402` _before_ any billable work runs.** On success it
  appends a negative-`delta` debit row to `credit_ledger`.

  `options.multiplier` bills a fan-out step once per unit of work (e.g. one Veo
  charge per shot in a multi-shot produce) as `cost × multiplier` in a **single
  all-or-nothing decrement** — either the whole fan-out is affordable or
  nothing is charged.

- `recordUsage(user, units, reason, options)` — a lower-level metered debit used
  for ad-hoc draws; same no-op-when-disabled rule, clamps the balance with
  `greatest(0, …)` and ledgers the debit.

- `grantCredits(accountId, amount, reason, options)` — appends a positive-`delta`
  credit row (plan top-ups, add-on purchases, manual grants, refunds). With
  `dedupe: true` it is **idempotent** (see below).

When billing is disabled, every metering call is a no-op, so the live free
product is unaffected.

### Owner & app-admins are never metered

Before it touches the ledger, `meterUsage` short-circuits for two principals and
returns `{ metered: false, exempt: true }` **without spending**:

```js
if (isPlatformOwner(user?.email) || user?.appAdmin) return { metered: false, exempt: true };
```

- **The platform owner** — `isPlatformOwner(user.email)`, keyed on the single
  `mainAdminEmail` constant (`sgirard@girardmedia.com`, case-insensitive) in
  [`src/permissions.js`](../../src/permissions.js).
- **Any app-admin** — `user.appAdmin` truthy.

This is the **safe-flip guarantee**: turning `BILLING_ENABLED=true` on can never
lock the owner or an admin out of their own platform, whatever their balance. It's
distinct from the effective-plan override (which opens *Pro gates*); this one
exempts *credit spend*. Pinned by a test in
[`test/billing.test.js`](../../test/billing.test.js) — see [Tests](#tests).

## Consumption confirmation (migration 038)

Every model run consumes credits — **self-hosted included** (open models are
*cheaper*, not free — see the `*-own` reasons above). So the owner directive is
that spending credits requires the user to **confirm** the consumption first. The
gate lives in [`src/consumption.js`](../../src/consumption.js) and is separate
from `meterUsage`: it runs **just before** the meter-and-spend on each paid route.

**The 402 contract.** `gateConsumption(user, { reason, multiplier?, opLabel?, body })`
throws when an op's credit cost (`USAGE_COSTS[reason] × multiplier`) is
`>= CONSUMPTION_CONFIRM_THRESHOLD_CREDITS` (env, default `1`; set `0` to confirm
*every* spend) **and** the user's effective setting requires confirmation **and**
the request didn't carry `confirm: true`. It reuses the **same** `402`
`premium_confirm_required` contract the video-generation gate already used
([`src/spectrum.js`](../../src/spectrum.js)'s dollar-threshold `confirmPremium`),
so one client dialog covers both:

```jsonc
// 402
{ "error": "This uses image — 5 credits. Confirm to proceed.",
  "code": "premium_confirm_required",
  "estimate": { "credits": 5, "opLabel": "image", "requiresConfirm": true } }
```

The caller retries the identical request with `confirm: true` merged into the body.

**Effective setting (most specific wins):** per-user override
(`app_users.require_paid_confirmation`, `null` = inherit) → per-workspace default
(`workspaces.require_paid_confirmation`) → `true`. Both columns and an
`audit_events` table are added by
[migration 038](../../migrations/038_consumption_confirm_audit.sql).

**Settings + audit.**

- `GET /api/settings/consumption` → `{ workspace, user, effective }`.
- `PUT /api/settings/consumption` with `{ scope: "user" | "workspace", requireConfirm }`
  saves it. For `scope: "user"`, a null `requireConfirm` clears the override
  (inherit). **Turning the confirmation off is audit-logged** — the handler calls
  `recordAuditEvent({ action: "disable_paid_confirmation", … })`
  ([`src/audit.js`](../../src/audit.js)).
- `GET /api/admin/audit` (God Console, `view_metrics`) lists recent audit events.

**Where it's wired.** The paid POST routes call `gateConsumption` immediately
before `meterUsage`: `/api/voice`, `/api/music`, `/api/imagen`, `/api/logo/options`,
`/api/content/write`, and every secondary transform — `/api/upscale`,
`/api/image/remove-bg`, `/api/image/vectorize`, `/api/audio/fx`, `/api/image/edit`,
`/api/image/subject-box`, `/api/image/assist`, `/api/captions/generate` +
`/highlights` + `/translate`, `/api/highlights/detect` + `/proxy`, and the diagram
AI route. Client side, [`editor/src/editor/consumption/fetch-with-confirm.ts`](../../editor/src/editor/consumption/fetch-with-confirm.ts)
(`fetchWithConfirm`) swaps in for `fetch` on paid POSTs: on a `402` it shows the
global `<ConsumptionConfirmModal>` and retries with `confirm: true`. The standalone
`/auto-clips` page runs its own `402 → confirm → retry`. Full phase plan:
[`planning/CREDITS_CONFIRM_AUDIT.md`](../../planning/CREDITS_CONFIRM_AUDIT.md)
(P1–P5 shipped).

## Where each reason is metered

All routes live in [`src/api.js`](../../src/api.js) unless noted. Recipe
(staged-workflow) steps meter the same reasons via `ctx.meter` in
[`src/recipe-runner.js`](../../src/recipe-runner.js).

Several generation routes resolve which reason to bill **from the model that will
run**, before metering, so a self-hosted open engine draws its cheaper `*-own`
lane while the vendor path stays premium:

| Route | Vendor reason | Self-hosted reason | Resolver |
|-------|---------------|--------------------|----------|
| `POST /api/generate` | `usage:veo` / `usage:kling` / `usage:wan` / `usage:hailuo` / `usage:seedance` | `usage:corereflex` / `usage:corereflex-lf` | `creditReasonForProvider` ([`src/providers.js`](../../src/providers.js)) |
| `POST /api/imagen` | `usage:imagen` | `usage:image-own` | `routeImageModel().creditReason` ([`src/image-engines.js`](../../src/image-engines.js)) |
| `POST /api/voice` | `usage:voice` | `usage:voice-own` | `voiceCreditReason(body.model)` ([`src/voice-engines.js`](../../src/voice-engines.js)) — a self-hosted model row (e.g. `kokoro`) bills `usage:voice-own` |
| `POST /api/music` | `usage:lyria` | `usage:music-own` | self-hosted lane unless `engine: "lyria"` |

| Reason | Metered by |
|--------|-----------|
| `usage:veo` (or the routed provider reason) | `POST /api/generate` (one shot, provider-routed); `POST /api/director/produce` (`multiplier: plan.shots.length`); recipe video step (per shot) |
| `usage:persona` | `POST /api/personas/:id/clip` |
| `usage:render` | `POST /api/render`; `POST /api/shorts`; `POST /api/auto-clips` (`multiplier:` clip count); recipe render step |
| `usage:lyria` / `usage:music-own` | `POST /api/music` (engine-routed); recipe music step |
| `usage:lyrics` | `POST /api/music/lyrics` |
| `usage:imagen` / `usage:image-own` | `POST /api/imagen` (engine-routed); `POST /api/logo/options` (`usage:imagen`, delivered count — see invariants); brand draft generation per logo option ([`src/brand-generate.js`](../../src/brand-generate.js)); recipe image step |
| `usage:imagen-edit` | `POST /api/image/edit` |
| `usage:voice` / `usage:voice-own` | `POST /api/voice` (model-routed); `POST /api/voice/design` (`usage:voice`); `POST /api/auto-clips/voiceover`; recipe voice step |
| `usage:audio-fx` | `POST /api/audio/fx` |
| `usage:upscale` | `POST /api/upscale`; recipe upscale step |
| `usage:remove-bg` | `POST /api/image/remove-bg` |
| `usage:vectorize` | `POST /api/image/vectorize` |
| `usage:content` | `POST /api/content/write` + `/rewrite`; `POST /api/captions/highlights` + `/generate` + `/translate`; `POST /api/highlights/detect` + `/proxy`; `POST /api/auto-clips/story`; `POST /api/image/subject-box` + `/assist`; the diagram AI route; recipe content step |
| `usage:stock` / `usage:stock-gen` | Stock-library fetch / generated stock asset |
| `usage:publish` | `publish()` in [`src/social.js`](../../src/social.js) (one credit per post, before the outbound relay); auto-clips scheduling reuses it — no double metering |

## Metering invariants

These are the correctness rules the call sites follow. Hold them when adding a
new metered route.

1. **Gate + spend atomically, before the billable work.** One SQL statement
   decrements only when the balance covers the cost (see above). Never
   check-then-act; never meter after the vendor call.
2. **Validate before metering.** SSRF guards (`safePublicUrl`), workspace
   binding (`assetUrlInWorkspace` / `keyInWorkspace`), and body validation all
   run **before** `meterUsage`, so a request that will be rejected is never
   charged.
3. **Honesty gate before metering.** Routes backed by an optional worker
   (`/api/audio/fx`, `/api/upscale`, `/api/image/remove-bg`,
   `/api/image/vectorize`, `/api/highlights/proxy`) return a clear `503`
   ("being provisioned…") when their worker URL env is unset — **never charge
   for a feature that can't run**.
4. **Meter delivered, not requested.** `meterUsage` is decrement-only with no
   refund path, so billing a *requested* count over-charges any shortfall with
   no way back. `POST /api/logo/options` pre-gates **one** image (so a $0
   account is rejected before any Vertex spend), generates, then reconciles the
   remainder to the **true delivered count** in one atomic decrement
   (`multiplier: result.variants - 1`).
5. **Clamp fan-outs, then meter per unit.** `POST /api/director/produce` clamps
   a client-supplied plan to `MAX_SHOTS` before metering (an unclamped
   hand-crafted plan was an unbounded vendor-cost vector even with billing
   off), then meters **once per shot** — the old flat single charge
   under-billed an 8-shot film 8×. `POST /api/auto-clips` does the same per
   clip (`AUTO_CLIPS_MAX` cap, then `multiplier: wanted`).
6. **Grants are idempotent; the webhook is exactly-once with
   mark-after-success and replay defense.** See the next three sections.

## Idempotent grants (migration 011)

A subscription start and every monthly renewal grant the plan's included credits.
Stripe **retries** a webhook until it gets a 2xx, so a naive grant could
double-credit an account.

[Migration 011](../../migrations/011_billing_credit_idempotency.sql) makes that
impossible at the database level with a **partial unique index**:

```sql
create unique index if not exists credit_ledger_grant_idem_idx
  on credit_ledger (account_id, reason) where delta > 0;
```

The index covers only **positive deltas** (grants / purchases), so usage debits
(`delta < 0`) are excluded and the same `usage:*` reason can still be metered
many times. `grantCredits(..., { dedupe: true })` then:

1. Inserts the ledger row with
   `on conflict (account_id, reason) where delta > 0 do nothing` — a duplicate
   grant for the same `(account_id, reason)` is a no-op, even under a webhook-retry
   race.
2. Only when the insert actually created a row does it bump `accounts.credits`
   and backfill `balance_after`. A deduped call returns `{ granted: 0, deduped: true }`.

The grant `reason` is a **stable key tied to the Stripe object**, so retries
collapse to one credit:

- activation → `grant:checkout:<session.id>`
- renewal → `grant:invoice:<invoice.id>`
- credit add-on → `grant:addon:<session.id>`
- storage add-on → `grant:storage:<session.id>` (deduped by migration 026's
  unique index instead — see below)

**Graceful pre-migration fallback:** if 011 hasn't been applied yet, the
`on conflict … where` clause raises Postgres error `42P10`; `grantCredits`
catches exactly that code and falls back to a best-effort `SELECT`-then-`INSERT`
dedupe so an enabled-billing webhook never 500s. Less race-safe, but correct in
the common (non-concurrent) case. Apply 011 to get the hard guarantee.

## Webhook exactly-once (migration 012)

Grant dedupe alone wasn't enough: a retried `checkout.session.completed` would
still **re-fire `payment_completed` automations**. [Migration 012](../../migrations/012_webhook_idempotency.sql)
adds `processed_webhook_events (event_id primary key)` and `handleWebhook`
dedupes **whole events** by Stripe event id.

The ordering is the invariant worth remembering — **mark AFTER success**:

- At the top of the handler, an already-recorded `event.id` short-circuits to
  `{ received: true, deduped: true }`.
- The processed marker is inserted **only at the very end, once the work
  succeeded** — not before. A transient failure mid-handler (say, a blip on the
  `accounts` update) leaves **no marker**, Stripe retries, and the idempotent
  work re-runs. Marking-before-work (the old order) let that blip permanently
  lose a paying customer's renewal credits.
- The rare double-delivery race this allows is harmless because every side
  effect above it (grant dedupe, account upserts) is itself idempotent.
- Both the check and the marker insert degrade gracefully if 012 isn't applied
  (table missing → proceed; grants remain idempotent).

## Storage packaging (migration 026)

Storage is packaged in three layers, all computed in `billing.js`:

```
effective limit = (accounts.storage_limit_bytes  ← manual per-account override, replaces the default
                   ?? PLAN_STORAGE_BYTES[plan])  ← plan default: 1 / 25 / 500 GiB
                + Σ storage_grants.bytes         ← purchased add-on bundles, stack on ANY plan
```

- `PLAN_STORAGE_BYTES` — `free: 1 GiB`, `pro: 25 GiB`, `enterprise: 500 GiB`;
  `planStorageBytes(plan)` falls back to the free tier for unknown plans.
- `STORAGE_ADDON_BYTES = 50 GiB` and `CREDITS_ADDON_UNITS = 1000` — one
  purchasable unit each (see [add-ons](#one-time-add-ons-1000-credits--50-gib)).
- `effectiveStorageLimit({ plan, override, grantsBytes })` implements the
  formula above.

[Migration 026](../../migrations/026_storage_grants.sql) creates
`storage_grants (id, account_id, bytes, reason, created_at)` with a **unique
index on `reason`** — the Stripe session id — so a retried webhook can't
double-grant, mirroring `credit_ledger`'s dedupe. `grantStorage` inserts with
`on conflict (reason) do nothing` and **degrades quietly** (`{ degraded: true }`)
on Postgres `42P01` if the table isn't migrated yet.

### Per-asset byte accounting

Byte accounting lives in [`src/brands.js`](../../src/brands.js) on the `assets`
table (migration 025):

- `recordAsset(user, { objectKey, contentType, byteSize, provenance })` upserts
  an `assets` row keyed `on conflict (bucket, object_key)` (updating
  `byte_size` / `content_type` on re-record). It is **best-effort and must never
  break generation** — it returns `null` when storage isn't configured or the
  write fails.
- `getAccountStorageUsage(user)` sums `assets.byte_size` across **all
  workspaces under the account**, and returns
  `{ usedBytes, limitBytes, planDefaultBytes, addonBytes }`. A `null`
  `limitBytes` means the tables (025/026) aren't migrated yet → **no
  enforcement**, so the endpoint and generation keep working pre-migration.
- Surfaces: `GET /api/brands/storage` (the dashboard storage meter + add-on
  upsell) and a **soft guard** in brand generation
  ([`src/brand-generate.js`](../../src/brand-generate.js)) — once
  `usedBytes >= limitBytes` it throws
  `402 Storage limit reached — add storage to keep generating`.

> Today only the brand-generation path records bytes and enforces the quota;
> full per-asset accounting across uploads/renders is the Files-pillar
> follow-up (`planning/FILES_PILLAR.md`).

## One-time add-ons: +1,000 credits / +50 GiB

`POST /api/billing/addon` sells one-time top-ups on **any** plan (including
Free) via two Stripe SKUs. `createAddonCheckoutSession(user, input)`:

1. Requires a **verified email** (`requireVerifiedEmail`, like checkout/portal).
2. Picks the SKU: `input.addon === 'storage'` → `STRIPE_PRICE_STORAGE_ADDON`,
   anything else → credits → `STRIPE_PRICE_CREDITS_ADDON`. `503` if the key or
   the price env is missing.
3. POSTs a `mode: 'payment'` (one-time, not subscription) Checkout Session with
   `metadata[addon]`, `metadata[accountId]`, `metadata[units]` (1000) and
   `metadata[bytes]` (50 GiB) — the webhook applies whatever the session says,
   so the unit sizes can change without stranding in-flight sessions.

Request/response:

```
POST /api/billing/addon
{ "addon": "storage", "successUrl": "…", "cancelUrl": "…" }
→ 200 { "url": "https://checkout.stripe.com/…", "id": "cs_…", "addon": "storage" }
```

On `checkout.session.completed` **with `metadata.addon`**, the webhook applies
the purchase idempotently on the session id and **skips plan activation**
(an add-on must never touch `plan` / `subscription_status`):

- `addon: 'credits'` → `grantCredits(accountId, units, 'grant:addon:<session.id>', { dedupe: true })`
- `addon: 'storage'` → `grantStorage(accountId, bytes, 'grant:storage:<session.id>')`

## Stripe integration (native REST)

No SDK. Four outbound calls and one inbound webhook, all plain `fetch`. All
three checkout/portal entry points call `requireVerifiedEmail(user)` first.

1. **Checkout** — `createCheckoutSession(user, input)` POSTs an
   `x-www-form-urlencoded` body to `https://api.stripe.com/v1/checkout/sessions`
   with `mode=subscription`, the plan's price id, `client_reference_id` =
   the account id, and `metadata[accountId]` / `metadata[plan]`. Returns the
   hosted-checkout `url`. `503` if `STRIPE_SECRET_KEY` is missing; `503` if no
   price is configured for the requested plan.
2. **Add-on checkout** — `createAddonCheckoutSession` (above), `mode=payment`.
3. **Billing portal** — `createBillingPortalSession(user, input)` POSTs to
   `https://api.stripe.com/v1/billing_portal/sessions` for the account's
   `stripe_customer_id`, returning the portal `url`. This is the **self-service**
   surface: the customer updates their payment method, switches plan, views
   invoices, or cancels — without us building any of it. `409` if the account
   has no subscription yet.
4. **Webhook** — `POST /api/billing/webhook` reads the **raw** request body
   (via `readRaw` in `api.js`, never JSON-parsed first, because the signature is
   computed over the exact bytes) and verifies the `stripe-signature` header.
   `verifyStripeSignature` recomputes `HMAC-SHA256(secret, "<t>.<payload>")` and
   compares against `v1` with `timingSafeEqual`. An invalid signature is a `400`.

   **Replay defense:** the signature's `t` timestamp must be within a tolerance
   window (default 300 s — Stripe's documented default) of the server clock,
   otherwise the signature is rejected even if the HMAC matches. Without this,
   a captured webhook could be replayed indefinitely. Tune with
   `STRIPE_WEBHOOK_TOLERANCE_S`; `0` disables the check.

### Webhook events handled

| Stripe event | Effect |
|--------------|--------|
| `checkout.session.completed` (no `metadata.addon`) | Set `plan` + `subscription_status='active'`, link `stripe_customer_id`, **grant included credits** (`grant:checkout:<id>`, deduped), and fire `payment_completed` automations for every workspace under the account. |
| `checkout.session.completed` with `metadata.addon=credits` | One-time credit top-up (`grant:addon:<id>`, deduped). No plan change. |
| `checkout.session.completed` with `metadata.addon=storage` | One-time storage bundle into `storage_grants` (`grant:storage:<id>`, unique on reason). No plan change. |
| `invoice.paid` / `invoice.payment_succeeded` | Monthly renewal — **refill included credits** (`grant:invoice:<id>`, deduped). |
| `customer.subscription.created` / `.updated` | Update `subscription_status`, `subscription_period_end`, and (if the price maps to a plan) `plan`. |
| `customer.subscription.deleted` | `subscription_status='canceled'`, `plan='free'`. |

Every event is additionally deduped by `event.id`
(see [Webhook exactly-once](#webhook-exactly-once-migration-012)).

The `payment_completed` fan-out uses a **dynamic import** of `automation.js` to
avoid the static `billing ↔ automation ↔ crm` import cycle, and is wrapped so a
failure there can never make the webhook return an error (which would make Stripe
retry). See [Client-Operations Layer](client-ops.md).

## Reading entitlements

`getEntitlements(user)` returns `{ plan, credits, subscriptionStatus,
subscriptionPeriodEnd, billingEnabled, checkoutAvailable, features }`. Two
behaviors matter:

- It **prefers the session's effective plan** (`user.workspace.plan`, already
  resolved by `resolveEffectivePlan`) over the raw billed plan from the DB —
  so the owner's Plan page and a view-as-tier preview match what the gates
  enforce. Real customers see no difference.
- It **degrades gracefully**: the account read is wrapped in try/catch, so if
  migration 006's `credits`/`subscription_*` columns aren't applied yet it falls
  back to the workspace's stored plan instead of 500-ing.

`GET /api/billing` returns this plus `clientOps: clientOpsEnabled(user)` so the
dashboard can show/hide the Pro suite (which also honors `CLIENT_OPS_ENABLED`,
something the plan alone doesn't reveal).

## The inert flag

`BILLING_ENABLED` (env, truthy = `true`/`1`) gates metering. `billingConfig()`
also reports `configured = Boolean(STRIPE_SECRET_KEY)`, which gates checkout/portal.

> **Now live.** `BILLING_ENABLED=true` is set in production, so metering is
> **active** — paid operations decrement real credits (and the consumption-confirm
> gate fires). The owner/app-admin exemption above is what makes that flip safe.
> The flag remains off by default for local dev and any deploy that hasn't set it.

When billing is unset/false:

- `meterUsage` / `recordUsage` are no-ops — nothing is charged.
- No credit-spend Stripe calls are made.
- `getEntitlements` still works (degrades to the stored plan if needed), so the
  app never 500s on a billing read.

When `STRIPE_SECRET_KEY` is unset, checkout, the add-on route, and the portal
return a clear `503` rather than failing opaquely.

To activate: set `BILLING_ENABLED=true`, `STRIPE_SECRET_KEY`,
`STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_PRO` / `STRIPE_PRICE_ULTRA` / `STRIPE_PRICE_ENTERPRISE`, the
add-on SKUs `STRIPE_PRICE_CREDITS_ADDON` / `STRIPE_PRICE_STORAGE_ADDON`, and
optionally `STRIPE_WEBHOOK_TOLERANCE_S` (default 300) and
`CONSUMPTION_CONFIRM_THRESHOLD_CREDITS` (default 1), then apply migrations
`006`, `011`, `012`, `026`, and `038` (consumption-confirm + audit). See
[Configuration](configuration.md).

## API surface

| Route | Auth | Purpose |
|-------|------|---------|
| `GET /api/billing` | user | Entitlements + credits + client-ops gate. |
| `POST /api/billing/checkout` | user (verified email) | Start a subscription → hosted Stripe Checkout URL. |
| `GET /api/billing/addons` | user | Catalog of credit + storage packs (`listAddonPacks`). |
| `POST /api/billing/addon` | user (verified email) | One-time pack checkout. Body: `{ packId }` (e.g. `credits-5k`, `storage-200`) or legacy `{ addon: "credits"\|"storage" }`, optional `quantity` 1–20. |
| `POST /api/billing/portal` | user (verified email) | Self-service Stripe Billing Portal URL. |
| `POST /api/billing/webhook` | Stripe sig (+ timestamp tolerance) | Activation / renewal / add-ons / subscription state. |
| `GET /api/brands/storage` | user | `{ usedBytes, limitBytes, planDefaultBytes, addonBytes }` for the storage meter. |
| `GET·PUT /api/settings/consumption` | user | Read `{ workspace, user, effective }`; save a per-user/per-workspace confirm setting (disabling is audit-logged). |
| `GET /api/admin/audit` | admin (`view_metrics`) | Recent audit events (incl. `disable_paid_confirmation`) for the God Console. |
| `POST /api/admin/preview-plan` | owner (`god_mode`) | View-as-tier: re-mint the owner session with a preview-plan claim. |

## Gates summary

| Gate | Enforced by | Tier |
|------|-------------|------|
| Watermark removal | render worker flips `manifest.watermark` off for Pro+ | Pro+ |
| Credit spend | `meterUsage` 402 (atomic, race-safe) | any (when `BILLING_ENABLED`) |
| Consumption confirm | `gateConsumption` 402 `premium_confirm_required` before the spend | any spend ≥ threshold, unless disabled per user/workspace |
| Storage quota | soft guard in brand generation (402) + `/api/brands/storage` meter | limit = plan + override + add-ons |
| Private / enterprise generation | generation mode | Enterprise |
| Client operations | `clientOpsEnabled` | Pro+ (or `CLIENT_OPS_ENABLED`) |
| Owner override (Pro gates) | `resolveEffectivePlan` (session-level) | owner → top tier, always |
| Owner / app-admin spend exemption | `meterUsage` returns `exempt` | owner + app-admins never metered |

## Tests

- [`test/billing.test.js`](../../test/billing.test.js) — plan ranking, the
  atomic gate (concurrent double-spend rejected), the 402-before-spend path,
  the **owner/app-admin never-metered exemption** (the safe-flip guarantee,
  owner email case-insensitive), effective-plan preference in `getEntitlements`,
  grant dedupe (the `42P10` fallback included), signature verification (incl.
  forged-signature reject), checkout/portal verified-email + `409`/`503` paths,
  the webhook handlers, event-id dedupe, and the mark-AFTER-success
  retryability guarantee.
- [`test/consumption.test.js`](../../test/consumption.test.js) — the
  consumption-confirm gate: threshold behavior, effective-setting resolution
  (user override → workspace → default), the `402 premium_confirm_required`
  contract, and `confirm: true` passthrough.
- [`test/brand-storage.test.js`](../../test/brand-storage.test.js) — storage
  tiers + override + add-on stacking, `grantStorage` idempotency and `42P01`
  degradation, the add-on checkout session metadata, the storage-add-on webhook
  path (skips plan activation), `getAccountStorageUsage` (effective limit and
  pre-migration no-enforcement), `recordAsset` upserts, and brand-draft byte
  recording.
- [`test/admin-api.test.js`](../../test/admin-api.test.js) — `setPlanPreview`
  re-mints the owner session with the claim and rejects unknown tiers.

Run `npm test`.

Related: [Client-Operations Layer](client-ops.md) · [Configuration](configuration.md) · [Security](security.md) · [Plans & Billing (user)](../user/plans-and-billing.md).
