> ## Documentation Index
> Fetch the complete documentation index at: https://docs.findmydata.io/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR 0021 stripe billing

# ADR-0021: Stripe billing — paid conversion (Checkout + signed webhooks)

**Status:** Accepted · 2026-07-15

## Context

The customer journey's final step (web design §5/§9) is **convert**: an evaluation
becomes a paid subscription without reinstalling the customer platform. This ADR
adds Stripe billing to the Account portal, upgrading the account's edition on the
existing control registry so the deployment picks up the paid plan on its next
renewal/activation — no reinstall.

## Decisions

### 1. Stripe Checkout for payment; the webhook is the source of truth

An org owner starts an upgrade → the portal creates a **Stripe Checkout Session**
(subscription mode) and redirects to Stripe-hosted checkout (PCI stays entirely
with Stripe; the portal never sees card data). The **entitlement change happens
only on the verified webhook** (`checkout.session.completed`), never on the
redirect back — the success URL is UX, the webhook is truth. The account id +
target edition ride along as `client_reference_id` + `metadata` (on the session
*and* the subscription) so the webhook attributes the payment without extra
lookups.

### 2. The webhook signature is the trust boundary, verified over raw bytes

`billing-stripe.ts` implements Stripe's `Stripe-Signature` scheme itself (no
SDK): HMAC-SHA256 over `` `${t}.${rawBody}` `` with the webhook signing secret,
**constant-time** compared (length-guarded) against any `v1` signature (key
rotation), with a **300 s timestamp tolerance** for replay protection. The route
reads the **exact raw body** (`c.req.text()`) and verifies **before** parsing or
acting. An event is acted on only after the signature verifies — so an
account can be upgraded/downgraded only by a genuine Stripe event, not a forged
POST.

### 3. Upgrade maps to the existing plan model; cancellation reverts

On a verified upgrade, `updateAccountPlan(planForEdition(edition))` replaces the
`cp_account`'s edition/features/capacity/term (subscription), and the Stripe
customer + subscription ids are stored. `customer.subscription.deleted` reverts
the account to the evaluation plan. Re-delivered events are **idempotent** (the
same plan re-applied is a no-op in effect). A verified event always returns 200
(even unhandled types) so Stripe stops retrying; only signature failures 4xx.

### 4. Checkout is authorized; secrets come only from the environment

The checkout route is **member-guarded + CSRF** (a non-member can't start a
checkout for another org, and can't pick an arbitrary price — the price is
resolved server-side from the requested edition via the configured
price→edition map). Billing is **feature-gated**: off unless both the Stripe
secret key and webhook secret resolve (env refs) and at least one price mapping is
set. The Stripe **secret key never leaves the environment** and is used only
server-side to create sessions; nothing sensitive is logged. Pricing (the actual
Products/Prices) is a business decision configured by the operator in Stripe —
the code references price ids, never amounts.

## Consequences

* The commercial loop is complete end to end: evaluate → download → activate →
  support → **convert (pay)** → the deployment upgrades on next renewal.
* The portal holds a Stripe secret key server-side (like the Control signing key)
  — resolved from env, never committed; PCI scope stays with Stripe.
* Out of scope (future): a billing portal/self-service plan management (Stripe
  Customer Portal), proration/seat metering tied to capacity meters, dunning, and
  invoice history in the portal UI.

## Verification

`account/billing-stripe.test.ts` (14) — signature accept; reject on wrong secret /
missing header / tampered body / stale timestamp (replay); multi-`v1` rotation;
event→intent extraction (paid only); checkout session creation (form/metadata +
API error); and the HTTP flow: checkout 503-when-disabled / 404-non-member /
403-no-CSRF / 400-evaluation / 201-with-url, and a **signed webhook that actually
upgrades the account edition + records the Stripe ids**, invalid-signature refusal
(no upgrade), cancellation revert, and idempotent re-delivery. All 304 server
tests pass; typecheck + lint clean; the upgrade UI verified in a browser;
adversarially reviewed.
