---
title: Subscription Rollout — Master Spec (v3)
---

# Subscription Rollout — Master Spec (v3)

## Context

End-to-end engineering rollout for the n1.care subscription model. Sits on top of the [product spec sheet](/guides/subscription-rollout/subscription-model-spec/), which defines the commercial rules.

**Core principle:** Phases 1-5 ship in one cutover. The five phases are dependency stages — a decomposition of the work, not a time-phased release schedule. Phase N depends on Phase N-1's schema and endpoints, but all five go out in one deploy. Phase 6 wraps around that deploy: T&C and user-facing communication must land **before** cutover, code cleanup **after**.

**Tiny user base assumption:** all existing users are trial-level. Migration is a one-time, low-ceremony switch — every existing user is hard-migrated to Starter with `charge_exempt=true` at cutover, and the migration backfill stamps `tc_accepted_version` with the current T&C version (acceptance inferred from Phase 6's 14-day advance email + in-app banner). `charge_exempt` is a **narrow flag on the overage billing path only** — it suppresses Stripe metered-usage posts for overage; everything else (subscription row, quota enforcement, normal app functionality, base-plan Stripe invoicing where a Stripe subscription exists) behaves identically to a non-exempt user. Migrated users also happen to have no Stripe subscription yet because they haven't gone through Checkout — that's orthogonal to the flag. An admin endpoint lets ops flip `charge_exempt=false` per user so overages get billed normally when they're ready.

## Phase map

| # | Phase | Scope |
|---|-------|-------|
| 1 | Usage counters + upload review | Page counting, decoupled extract, review-before-parse. No billing. |
| 2 | Plan & subscription model + usage ledger + frontend swap | `plans` + `subscriptions` (incl. `charge_exempt`) + `quota_events` tables; `/quota/charge` shell; Contract B; **full frontend swap** — `QuotaStatusCard` + `QuotaIndicator` replace the legacy `BillingPage` / balance widgets / credit top-up flows / "insufficient funds" UX; admin endpoint + admin-dashboard UI for `charge_exempt`. No enforcement — writes rows, doesn't 429. Backend-side legacy billing code stays until Phase 6. |
| 3 | Paid plan enforcement + Stripe + overage | Paid-plan branches in `/quota/charge`; per-event Stripe usage posts keyed on `quota_events.id`; Checkout (Contract D) + overage toggle (Contract E); Stripe webhooks; reprocess; pick-a-plan. |
| 4 | Portal + emails + read-only banner | Customer Portal (Contract F); four transactional email templates (receipt, payment-failed, plan-changed, subscription-canceled); `ReadOnlyBanner`. |
| 5 | Public free trial | Trial signup intent; lifetime-cap branch in `/quota/charge`; trial banner; upgrade-on-cap modal; abuse guards. |
| 6 | Cleanup, T&C, and user-facing communication | Updated Terms & Conditions. Pre-cutover user emails + in-app banner. Help-center articles. Pricing-page refresh. Support playbook. Post-cutover code cleanup (rollout-flag removal, TODO closeout). |
Detail: [phase-1.md](/guides/subscription-rollout/phase-1/) · [phase-2.md](/guides/subscription-rollout/phase-2/) · [phase-3.md](/guides/subscription-rollout/phase-3/) · [phase-4.md](/guides/subscription-rollout/phase-4/) · [phase-5.md](/guides/subscription-rollout/phase-5/) · [phase-6.md](/guides/subscription-rollout/phase-6/)

## Per-phase problem → solution

### Phase 1
**Problem:** we don't know what doctors consume, and today's upload flow auto-fires parsing — no review, no page-count visibility.
**Solution:** count pages on every upload; decouple parse from upload so the doctor sees a review screen listing each file + its page count and explicitly clicks Extract.

### Phase 2
**Problem:** users see raw counts with no meaning, there's no data model tying a doctor to a billing tier, and usage data lives in api-backend — billing-service can't answer "how many pages has Dr. Smith committed?" without a cross-service call. The frontend is also still wired to the pre-subscription LiteLLM-budget model (old `BillingPage`, balance widgets, "credits remaining" copy, "insufficient funds" error paths). Separately, existing users need a subscription row at cutover without triggering Stripe billing.
**Solution:** introduce `plans` + `subscriptions` (including a `charge_exempt` flag) + `quota_events` in billing-service. Every extract click calls `/quota/charge`, which writes one immutable `quota_events` row. Billing-service becomes the single source of truth for usage. Existing users are hard-migrated to Starter with `charge_exempt=true` — full subscription row + normal app functionality, only the overage Stripe post path is suppressed. Admin endpoint + admin-dashboard UI let ops toggle the flag per user. **Same phase swaps the frontend fully off the old billing surfaces** — legacy `BillingPage`, balance widgets, credit top-up flows, and "insufficient funds" UX are deleted; `QuotaStatusCard` + `QuotaIndicator` are the only billing UI. **No enforcement** — `/quota/charge` writes rows but doesn't 429, paid-plan branches arrive in Phase 3. Backend-side legacy billing code (LiteLLM budget / balance checks in the extraction pipeline) stays live; nothing user-facing reads those anymore, and they get swept in Phase 6.

### Phase 3
**Problem:** Phase 2 wrote rows but enforced nothing — no paid plans, no checkout, no block on overuse, no overage.
**Solution:** paid-plan branches in `/quota/charge` (quota check under `SELECT FOR UPDATE`, 429 on exhaustion, overage when toggled on), Stripe Checkout for new signups, Stripe webhooks for state sync, per-event Stripe usage posts (one per overage-bearing `quota_events` row, idempotency-keyed on `quota_events.id` — no local meter aggregates), free reprocessing via `(source_type, source_id)` replay against the ledger's UNIQUE constraint.

### Phase 4
**Problem:** no self-serve plan change UI, no transactional emails, no in-app explanation when an account goes read-only.
**Solution:** Stripe Customer Portal endpoint. Four transactional email templates (receipt, payment-failed, plan-changed, subscription-canceled) wired into the Stripe webhook handlers. `ReadOnlyBanner` driven by `subscription.status`.

### Phase 5
**Problem:** requiring a credit card on signup kills top-of-funnel conversion.
**Solution:** public "Start Free Trial" (100 pages + 1 report, lifetime). On cap-hit, upgrade modal. Abuse guards bound economic exposure.

### Phase 6
**Problem:** the rollout can't ship silently. Existing users logging in post-cutover and finding they're now metered against a 500-page Starter quota need advance warning or it looks like an outage. The current T&C predate the subscription model — no language on quota, overage, trial terms, cancellation, or data retention — which is both a legal gap and a support liability. Phases 1-5 also leave rollout-only scaffolding behind: feature flags that gated the cutover, staging-only fallbacks, TODOs across four repos.
**Solution:** three bundles of work wrapped around the cutover. (1) **Pre-cutover legal:** rewritten T&C covering quota, overage (including the fact that disabling mid-period doesn't cancel already-accrued overage — §5), trial, cancellation, data retention (§7), and n1-initiated price changes (§6: new prices from next renewal only, 30-day advance notice, no mid-cycle increases); linked from signup and Portal; legal sign-off required before deploy. (2) **Pre-cutover communication:** email to every existing user explaining the migration two weeks before cutover; in-app banner for the two weeks leading up to it; refreshed pricing page; new help-center articles (pricing, overage, reprocessing, cancellation, trial, how future price changes are communicated); support playbook with canned responses for the new failure modes (`past_due`, quota exhausted, trial ended, "why am I on Starter?") and a templated response for future price-change questions. (3) **Post-cutover code cleanup:** remove rollout-only feature flags, delete any staging fallbacks that were only load-bearing during the cutover window, resolve accumulated TODOs across billing-service / api-backend / authentication-service / react-frontend.

## Shared data objects (end state)

| Table | Introduced | Purpose |
|---|---|---|
| `plans` | Phase 2 | Tier catalog. |
| `subscriptions` | Phase 2 | One row per doctor. `charge_exempt` (admin-controlled, narrow flag — suppresses overage Stripe posts only; subscription row, quota enforcement, app functionality, and base-plan invoicing unchanged) and T&C columns land in Phase 2. `overage_enabled` + `pages_stripe_item_id` + `reports_stripe_item_id` added in Phase 3 (overage metered-usage is posted per `quota_events` row using `quota_events.id` as the Stripe idempotency key — no local meter aggregates). `lifetime_pages_used` + `lifetime_reports_used` added in Phase 5. |
| `quota_events` | Phase 2 | Append-only event log — one row per `/quota/charge` call. Period balance = `SUM(count)` scoped to `(subscription_id, period_start, kind)`. Immutable: INSERT + SELECT only. Full traceability via `source_type` + `source_id`. Also the source of truth for Stripe overage posts in Phase 3 — `id` is the idempotency key and `overage_count` is the quantity. |
| `processed_stripe_events` | Phase 3 | `stripe_event_id` dedup for webhook re-delivery. |
Phase 1 adds a `page_count` column to the existing `record_requests` table — no new tables.

## Shared contracts

**Gateway rule:** frontend never calls billing-service directly. Every user-facing billing call lands on api-backend (JWT auth); api-backend forwards to billing-service with an N1 API key. Billing-service's routes are N1-API-key-only — it has zero JWT handling. Exception: Stripe webhooks, which Stripe fires directly at billing-service (internet-exposed, signature-verified).

| Contract | Introduced | billing-service (N1 API key) | api-backend (user JWT) |
|---|---|---|---|
| B | Phase 2 | `GET /quota/status/{user_id}` — plan + usage (`SUM(quota_events)`) + remaining, unified. | `GET /billing/quota-status/me` — pass-through. |
| C | Phase 2 | `POST /quota/charge` — single transactional charge; append-only event write. Phase 2 = shell + Enterprise/unlimited path; Phase 3 adds paid-plan + overage + 429 branches; Phase 5 adds the lifetime-cap branch. | Called from `/records/batch-extract` + report endpoints — never hit from the frontend. |
| D | Phase 3 | `POST /subscriptions/checkout` — Stripe Checkout Session. | `POST /billing/checkout` — pass-through. |
| E | Phase 3 | `PATCH /subscriptions/{user_id}/overage`. | `PATCH /billing/overage` — pass-through. |
| F | Phase 4 | `POST /subscriptions/portal` — Stripe Customer Portal session. | `POST /billing/portal` — pass-through. |
Request/response shapes live in the phase doc that introduces the contract. Never renamed. No service-to-service "Contract A" — billing-service reads its own `quota_events` for usage, so there's no cross-service aggregation call.

## Cross-cutting guarantees

### Non-blocking billing calls
From api-backend to billing-service, only `/quota/charge` is allowed to block a user action (it's what decides whether the click succeeds). Everything else (Stripe metered-usage report to the Stripe API) runs in a background task with 3 bounded retries (500ms / 2s / 8s backoff) and a 2s per-call timeout. Exhaustion → ERROR log with the `(source_type, source_id)` of the originating charge for manual replay. Exception: the Step 5 registration hook on billing-service **is** blocking — a registration that can't create a subscription returns 5xx and fails signup, by design (no half-created users).

### Stripe webhook signature verification
Every Stripe webhook endpoint **must** call `stripe.Webhook.construct_event(payload, sig_header, webhook_secret)` before any other logic. Secret from env. Unverified payloads → 400 immediately.

### Stripe API timeouts
All `stripe.*` calls pass `timeout=5`. Retries bounded to 3 attempts, 500ms / 2s / 8s backoff. Exhaustion → ERROR log + alert + user-visible failure.

### Charge rate limit
billing-service rate-limits `POST /quota/charge` to 60 req/min per user. Exceeded → 429 with `Retry-After`. Prevents a bug or compromised API key from burning a month's quota in seconds.

### Idempotency
- **Charges** (`POST /quota/charge`): natural idempotency via `UNIQUE(source_type, source_id)` on `quota_events`. A replay (retry after network blip, reprocess a failed parse, double-click on Extract) resubmits the same `(source_type, source_id)` tuple → the transaction's first step finds the existing row and returns it unchanged. No double-charge, no refund logic.
- **Subscription registration** (`POST /subscriptions/register-user`): idempotent on `n1_user_id` via `UNIQUE(subscriptions.user_id)` — replay returns the existing subscription.
- **Stripe webhooks**: idempotent via `stripe_event_id` stored in `processed_stripe_events`. Double-fire is a no-op.
- **Stripe Checkout / Portal sessions**: each call creates a new short-lived Stripe session URL — the client only uses the latest.

### Server-authoritative everything
Signup intent, plan codes, user ids, quota values — all resolved server-side. Frontend is untrusted. A tampered `signup_intent` never grants free access.

### User isolation — no cross-user charges
One user can never cause a charge, quota deduction, or billing state change on another user's subscription. Enforced at every boundary:

- **api-backend resolves `user_id` from the JWT claim only** — never from request body, query, path, or header. Applies to `/billing/quota-status/me`, `/billing/checkout`, `/billing/overage`, `/billing/portal`. Endpoints are suffixed `/me` or key off `jwt.sub`; no route accepts a `user_id` parameter from the client.
- **No `/billing/*/{user_id}` on api-backend.** No legitimate use case for a user to read or mutate another user's billing state. Ops reads go direct to billing-service with the ops API key.
- **`/quota/charge` source ownership.** api-backend only calls `billing_client.charge(source_id=...)` for records/reports it has already loaded scoped to `caller.caller_id`. A foreign `record_request_id` in a request body cannot reach billing-service because it never appears in the loaded set — it 404s in api-backend before the charge call.
- **billing-service trusts api-backend's authorization** — it does not re-verify JWT (it never sees one) and accepts the `user_id` forwarded over the N1 API key. The trust boundary is api-backend's JWT validation + source-ownership check; billing-service's job is transactional integrity (idempotency, locking, ledger immutability), not user auth.
- **Stripe webhook events resolve user_id via `subscriptions.stripe_customer_id` lookup** on the signature-verified event payload. Webhook body metadata is not a trust channel.
- **Admin endpoints use a separate `ADMIN_API_KEY`** (not the general N1 API key). Compromise of the api-backend → billing-service credential does not grant comp-status, cross-user mutation, or plan-switch rights.
- **Charge rate limit is per `user_id` in the body**, not per API key (see above). A bug in api-backend that loops cannot burn one specific doctor's quota — the 60 req/min ceiling trips first.

## Scope boundaries (deliberately NOT in this rollout)

- Team accounts / seats (patient count unlimited).
- Per-patient quota.
- Volume discounts beyond published tiers.
- Annual billing.
- Referrals, coupons, promo codes.
- Multi-currency.

## Risk register

| Risk | Mitigation |
|---|---|
| Page-count miscount on edge formats | 5-doctor hand-sample in Phase 1 (Step 8 SQL). |
| Stripe webhook out-of-order | All handlers idempotent via `stripe_event_id`. |
| Quota race between parallel extracts | `SELECT … FOR UPDATE` on subscription row in the charge transaction; current-period balance read via indexed SUM over `quota_events`. |
| Parse failure with charge already committed | Free reprocessing: same `(source_type, source_id)` tuple replays the existing `quota_events` row — no new charge, no refund logic. |
| Stripe timeout cascades to user-visible failure | Explicit 5s timeout + bounded retry + graceful message. |
| Free Trial LLM spend blowout | Daily spend alert + 1 concurrent parse per trial user + per-IP signup rate limit. |
## Ownership

One engineering pod, end to end. Each phase has "Done when" criteria in its doc — used to validate the phase slice in staging before cutover, not as a gate to delay later phases.
