---
title: Phase 3 — Paid Plan Enforcement + Stripe + Overage
---

# Phase 3 — Paid Plan Enforcement + Stripe + Overage

## Problem → Solution

**Problem:** Phase 2 stood up the ledger and the `/quota/charge` shell, but paid-plan enforcement is unwired, no Stripe Checkout exists for new signups, a willing customer has no way to pay for overflow, and parse failures leave doctors with a charge and no recourse.
**Solution:** add paid-plan branches to `/quota/charge` (quota check under `SELECT FOR UPDATE`, 429 when exhausted, overage when toggled on), a Stripe Checkout flow for new signups, a Customer Portal session endpoint, overage metered-billing wiring, and free reprocessing via `(source_type, source_id)` replay against the ledger's UNIQUE constraint. Migrated users (Starter + `charge_exempt=true`) also route through the paid-plan branch — the branch enforces quota regardless of `charge_exempt`; the flag narrowly suppresses the overage Stripe post in the overage sub-branch. Everything else (row writes, in-quota enforcement, 429s, app functionality) is identical for charge-exempt users.

## What this phase adds to the user experience

- New signups land on a "pick a plan" screen — Starter / Professional / Practice — paid via Stripe Checkout.
- Review screen shows remaining quota ("412 of 2,000 left · after extract: 365") before the Extract click.
- Hitting a hard cap opens a quota-exceeded modal with two clear options: enable overage or upgrade.
- "Allow extra usage beyond my plan" toggle on the billing page; subsequent extracts that exceed quota succeed with metered overage.
- One-time confirmation modal the first time a doctor's charge silently overflows into overage in a period.
- Failed records / reports can be reprocessed at no cost (idempotency-safe, no second charge).
- Migrated users (Starter + `charge_exempt=true`) hit the same quota enforcement path as paying Starter subscribers — they get 500 pages / 5 reports per month, 429 on exhaustion, no overage (toggle hidden when `stripe_subscription_id` is null). If ops decides to onboard them to paid billing, they flip `charge_exempt=false` via the Phase 2 admin endpoint.

No portal UI for plan changes, no transactional emails — that's Phase 4. No public free trial — that's Phase 5.

## Goal

- New signups pick a plan → Stripe Checkout → webhook updates the subscription row.
- `/quota/charge` enforces quota on paid plans under `SELECT FOR UPDATE`.
- Quota exhausted + overage OFF → 429 + `QuotaExceededModal`.
- Quota exhausted + overage ON → charge succeeds, overage accrues, Stripe meter billed at period end.
- Parse failures recovered by free reprocessing (replay hits the `UNIQUE(source_type, source_id)` row → no double-charge).

## Repos touched

| Repo | Changes |
|---|---|
| **billing-service** | `subscriptions.overage_enabled` + `subscriptions.pages_stripe_item_id` + `subscriptions.reports_stripe_item_id`, paid-plan branches in `/quota/charge`, Contracts D + E (N1-API-key only), Stripe webhooks |
| **api-backend** | Handle `QuotaExceeded` / `BillingUnavailable` in the batch-extract loop; `POST /records/{id}/reprocess` + `POST /reports/{id}/reprocess`; user-facing pass-throughs `POST /billing/checkout` (Contract D) + `PATCH /billing/overage` (Contract E) |
| **react-frontend** | Pick-a-plan route, quota-line augmentation on the Phase 1 review screen, `QuotaExceededModal`, `FirstOverageConfirmModal`, `OverageToggleCard`, reprocess action |
| **authentication-service** | Post-registration redirect to pick-a-plan for non-Legacy signups |
---

## Step 1 — New columns on `subscriptions`

**Problem:** `subscriptions` has no column representing whether billable overage is allowed for that doctor — Contract E in Step 4 has nothing to read or write. Separately, enabling overage provisions Stripe metered `SubscriptionItem`s for pages + reports, and Step 2's per-event usage post needs to target those items by id without re-querying Stripe on every charge.
**Solution:** add three columns in one migration.

```sql
ALTER TABLE subscriptions
  ADD COLUMN overage_enabled         BOOLEAN NOT NULL DEFAULT FALSE,
  ADD COLUMN pages_stripe_item_id    TEXT NULL,
  ADD COLUMN reports_stripe_item_id  TEXT NULL;
```

`overage_enabled` defaults OFF per product spec ("no surprise invoices"). `pages_stripe_item_id` and `reports_stripe_item_id` are null until Contract E (Step 4) enables overage for that user — at that point Contract E creates the Stripe `SubscriptionItem`s and writes the returned item ids here. No separate `overage_meters` table — the per-event usage post (Step 2) keys off `quota_events.id` via Stripe idempotency keys, so Stripe itself holds the post-state and we avoid a second local source of truth that would need to stay in lockstep with the ledger.

---

## Step 2 — Paid-plan branches in `/quota/charge`

**Problem:** Phase 2 shipped the `/quota/charge` shell with only the Enterprise/unlimited path wired. Paid users (and migrated Starter users) have no enforcement yet.
**Solution:** extend the endpoint with paid-plan logic under the same `SELECT FOR UPDATE`. Enterprise path untouched.

### Transaction (full scope after Phase 3)

1. Existing `(source_type, source_id)` row → fetch it, return same 200 response. No mutation. (Phase 2 owns this lookup — paid branch just inherits it.) If the request sets `replay_only=true` and no existing row is found → 409 `no_prior_charge` (never fall through to the write path). Used by the reprocess flow (Step 9) to block pre-cutover FAILED records from being charged weeks after upload.
2. `SELECT … FOR UPDATE` on the subscription row. Fetch `plan`, `overage_enabled`, Stripe status. Compute `(period_start, _)` via `compute_current_period(sub)`.
3. Sub status is anything other than `active` → 402 `subscription_inactive`. (Spec §7: declined payment is immediately read-only, no grace period — so `past_due`, `canceled`, and `incomplete` all 402 here.) Migrated `charge_exempt=true` users are always `active`, so they don't 402 here.
4. `plan.pages_quota IS NULL` (Enterprise) → Phase 2 path, unchanged.
5. Paid plan (Starter / Professional / Practice — includes migrated users on Starter):
   - `used = SELECT COALESCE(SUM(count), 0) FROM quota_events WHERE subscription_id=? AND period_start=? AND kind=?`
   - `available = plan.pages_quota - used`
   - `count <= available` → write row, `overage_count = 0`. 200.
   - Else if `overage_enabled AND plan.pages_overage_cents IS NOT NULL AND NOT sub.charge_exempt`:
     - `in_quota = max(0, available)`, `overage = count - in_quota`
     - Write `quota_events` row with `overage_count = overage`. 200 with overage details.
   - Else: 429 with `{remaining, requested, overage_available}`. No row written.

   `charge_exempt=true` users never accrue overage — the explicit check in the condition above suppresses the overage branch entirely, regardless of whether they have a Stripe subscription item. The `overage_enabled` toggle is hidden in the frontend for charge-exempt users (Contract B response signals this via the derived `billing_setup_complete` and the overall `charge_exempt` branching in the UI); Step 4 additionally rejects server-side attempts to enable overage on a charge-exempt subscription.
6. `is_lifetime_cap` (free_trial): Phase 5 adds this branch.
7. **After the DB transaction commits** — not inside it — if the written row has `overage_count > 0`, enqueue a per-event Stripe usage post in the existing background retry pattern (`app/utils/retry.py` — 3 attempts, 500ms/2s/8s backoff, 5s per-call timeout). The job reads the subscription's `pages_stripe_item_id` / `reports_stripe_item_id` (written by Contract E at enable-time, Step 4), then calls:

   ```python
   stripe.SubscriptionItem.create_usage_record(
       id=item_id,                         # pages_stripe_item_id or reports_stripe_item_id
       quantity=event.overage_count,
       action="increment",                 # Stripe adds this to the running period total
       timestamp=int(event.occurred_at.timestamp()),
       idempotency_key=str(event.id),      # the quota_events row id — one post per event, forever
       timeout=5,
   )
   ```

   **Why post-commit:** enqueueing inside the transaction risks a ghost post — the enqueue succeeds, the transaction rolls back, and Stripe now has a usage record that corresponds to no `quota_events` row. Post-commit means "row exists in the ledger → post to Stripe"; a rollback means nothing was enqueued. The idempotency key protects the other direction (enqueue happens, worker crash before post, retry): Stripe dedups on `quota_events.id`.

   Stripe is the post-state authority; `quota_events.id` as idempotency key means any retry (network blip, webhook replay, worker restart) lands on the same Stripe usage record — no double-report, no local `last_reported_count` to keep in lockstep with the ledger. Retry exhaustion → ERROR log with `quota_events.id` for manual replay (spec requirement from the "Non-blocking billing calls" guarantee in master).

### New response shapes

**200 (with overage):**
```json
{ "event_id": "uuid", "in_quota_count": 3, "overage_count": 9, "overage_charge_cents": 90, "remaining": { "pages": 0, "reports": 5 } }
```

**429:**
```json
{ "error": "quota_exceeded", "remaining": { "pages": 8, "reports": 5 }, "requested": 12, "overage_available": true }
```

**402:**
```json
{ "error": "subscription_inactive", "status": "past_due" }
```
(`status` echoes the actual subscription status — `past_due`, `canceled`, or `incomplete` — so the frontend renders the right banner copy.)

### No /commit or /release

Parse succeeds → nothing to do, the charge already happened.
Parse fails → doctor hits Reprocess (Step 9), which re-submits the same `(source_type, source_id)` tuple → `UNIQUE` hits the existing event row, no new charge. Budget unchanged; no refund, no release.

---

## Step 3 — Contract D — Checkout via api-backend

**Problem:** billing-service has no way to originate a Stripe subscription payment — pick-a-plan has nothing to call. Frontend can't reach billing-service directly (gateway pattern).
**Solution:** two endpoints — api-backend fronts the user, billing-service owns the Stripe call.

### billing-service — `POST /subscriptions/checkout`

Auth: N1 API key.
Request: `{ "user_id": "uuid", "plan_code": "professional", "tc_accepted_version": "2026-05-01", "success_url": "...", "cancel_url": "..." }`.
Response: `{ "url": "https://checkout.stripe.com/..." }`.

Server-side validation:
- `plan_code ∈ {starter, professional, practice}`. `enterprise` rejected (custom contract, no Checkout path) and `free_trial` rejected (Phase 5 signup path).
- `tc_accepted_version == settings.tc_current_version` (exact string match). Mismatch → 400 `tc_version_stale` with the current version in the response body so the frontend re-prompts with the right copy. This is the **commit-time enforcement** — no Stripe session is ever created for a user who hasn't agreed to the current T&C.

### Two independent writes: T&C acceptance, then Stripe session

The endpoint does two things in strict order, and the first is not gated on the second:

1. **Record T&C acceptance** — `UPDATE subscriptions SET tc_accepted_version = :v, tc_accepted_at = now() WHERE user_id = :user_id`. Commits immediately. This is a standalone legal act: the user ticked the checkbox, we record it. It does not depend on the subsequent Stripe call succeeding.
2. **Ensure `stripe_customer_id`.** If the subscription row doesn't have one yet, `stripe.Customer.create(email=..., metadata={"n1_user_id": str(user_id)})`, persist the returned id on the subscription row, commit. Idempotent via the metadata: a re-run after a mid-flight crash finds the existing customer and reuses it.
3. **Create the Stripe Checkout session** — outside any DB transaction (network call, non-retryable under a row lock). `timeout=5`, 3 retries. Session is created with `customer=<stripe_customer_id>` so the eventual `customer.subscription.created` webhook (Step 5) can match the event back to the subscription row by `stripe_customer_id`.

If step 2 or step 3 fails after retries, step 1's write stands and the endpoint returns 503 `billing_unavailable`. The user retries from `PickPlanPage` and the frontend no longer shows the T&C checkbox (Contract B reports matching versions) — they land directly on "Continue to Checkout." No duplicate legal capture, no blocked retry loop on a transient Stripe outage.

This is deliberately **not** a two-phase commit: acceptance is durable from the moment the user clicks, independent of payment outcome. A user who accepts and then walks away from Checkout is recorded as having accepted the current T&C; their `subscriptions.status` stays `incomplete` until a Stripe session actually completes, which is what keeps the app in `PickPlanPage` via `billing_setup_complete=false`.

### api-backend — `POST /billing/checkout`

Auth: user JWT.
Body: `{ "plan_code": "professional", "tc_accepted_version": "2026-05-01", "success_url": "...", "cancel_url": "..." }`.

Resolves `user_id` from `jwt.sub`, forwards to billing-service verbatim, returns the Checkout URL. On `BillingUnavailable` → 503. On billing-service's 400 `tc_version_stale` → pass-through so the frontend can re-render the T&C with the new version.

---

## Step 4 — Contract E — Overage toggle via api-backend

**Problem:** the `overage_enabled` column has no write endpoint, and enabling overage also has to provision Stripe metered items. Frontend can't reach billing-service directly.
**Solution:** two endpoints.

### billing-service — `PATCH /subscriptions/{user_id}/overage`

Auth: N1 API key.
Body: `{ "enabled": true }`.
- Validates: plan has non-null overage rate, sub is `active` or `past_due`, `stripe_subscription_id IS NOT NULL`, `charge_exempt = false`. (No Stripe sub → no metered item to attach → 409 `no_stripe_subscription`. `charge_exempt=true` → overage would be suppressed anyway → 409 `charge_exempt_subscription`. The typical migration case trips the no-Stripe-sub check first; an admin-comped user with a live Stripe sub trips the charge-exempt check.)
- Enabling: if `subscriptions.pages_stripe_item_id IS NULL` or `subscriptions.reports_stripe_item_id IS NULL`, provision the missing Stripe `SubscriptionItem`s for this subscription (one `stripe.SubscriptionItem.create` per missing item, using the plan's `pages_overage_price_id` / `reports_overage_price_id`). Persist the returned ids on the `subscriptions` row, then set `overage_enabled = true` in the same transaction. Already-populated ids are reused — no second Stripe call on a re-enable.
- Disabling: column flip only (`overage_enabled = false`); the stored `pages_stripe_item_id` / `reports_stripe_item_id` are left in place — they reference live Stripe items, and a future re-enable should reuse them rather than spawning duplicates. Already-accrued overage still bills at period end.

### api-backend — `PATCH /billing/overage`

Auth: user JWT.
Body: `{ "enabled": true }`.

Resolves `user_id` from `jwt.sub`, forwards to billing-service. Ownership is implicit — api-backend only ever passes the JWT's own user_id down.

---

## Step 5 — Stripe webhook handlers

**Problem:** Stripe is the source of truth for subscription state, but billing-service has no endpoint receiving Stripe events — plan changes, period rolls, and payment failures in Stripe never reflect on our side.
**Solution:** new webhook file with mandatory signature verification on every handler.

**Every handler starts with:**
```python
event = stripe.Webhook.construct_event(
    payload, sig_header, settings.stripe_webhook_secret
)
```
Verification failure → 400 immediately, no DB writes.

All handlers idempotent via `stripe_event_id` — store processed ids in `processed_stripe_events(event_id PK, at)`; re-delivery is a no-op.

| Event | Action |
|---|---|
| `customer.subscription.created` | **UPDATE in place** — find the subscription row by `stripe_customer_id` (persisted at Checkout session creation, Step 3) and write `stripe_subscription_id`, `status`, and `current_period_start` from the event. Never INSERT — the row was created at registration (Phase 2 Step 5). Phase 5 Step 5 reuses this handler for trial-upgrade (same row, different prior `plan_id`). |
| `customer.subscription.updated` | Reflect plan changes + period rolls + `cancel_at_period_end` from Stripe. On period roll, new charges use the new `period_start`; old `quota_events` rows are frozen historical data. |
| `customer.subscription.deleted` | `status = canceled`. Fires at end of period for cancel-at-period-end, or immediately for hard-cancel. `/quota/charge` 402s. Account stays read-only indefinitely; data export still works (spec §7). |
| `invoice.paid` | No action on subscription state (period roll handled by `subscription.updated`). Email trigger lands in Phase 4. |
| `invoice.payment_failed` | `status = past_due`. `/quota/charge` 402s immediately (spec §7: no grace period). |
All `stripe.*` calls: `timeout=5`, bounded 3-retry.

**Period close:** once Stripe finalizes an invoice for a period, the subscription's `current_period_start` advances via `customer.subscription.updated`. New charges stamp the new `period_start`. Historical rows keep the old `period_start` — never rewritten.

---

## Step 6 — api-backend handles `QuotaExceeded` / `BillingUnavailable`

**Problem:** Phase 2 wired `billing_client.charge` into `/records/batch-extract`, but only the Enterprise-200 path was exercised. With paid users coming online, the loop needs to surface 429 and 503 outcomes per-record.
**Solution:** add two `except` branches to the existing loop.

```python
# routes/records.py — loop extended with the enforcement outcomes.
for record in records:
    if record.status != "SYNCED":
        results.append({"record_id": record.id, "status": "not_extractable"})
        continue

    try:
        charge = await billing_client.charge(
            user_id=caller.caller_id,
            kind="pages",
            count=record.page_count,
            source_type="record_request",
            source_id=record.id,
            timeout=2,
        )
    except QuotaExceeded as e:
        results.append({"record_id": record.id, "status": "quota_exceeded", **e.body})
        continue
    except BillingUnavailable:
        raise HTTPException(503, "billing temporarily unavailable")

    await queue_parse_job(record.id)
    results.append({
        "record_id": record.id,
        "status": "queued",
        "event_id": charge.event_id,
        "overage_count": charge.overage_count,
    })
```

Each `charge` inside the batch is its own transaction against `quota_events` — partial success is fine (some records queued, some 429'd), the frontend surfaces the mix. Idempotency keys off `(source_type=record_request, source_id=record.id)`, so a partial-batch retry replays already-charged records into the same event row instead of double-charging.

**Cancel path (unchanged from Phase 1):** `DELETE /records/{id}` allowed while SYNCED. With charges happening at extract, a SYNCED record always means "uncharged" — no extra check needed.

**Report flow:** report-generation endpoint gets the same two `except` branches — `charge(kind="reports", count=1, source_type="report_gen", source_id=<gen_id>)`.

---

## Step 7 — Augment the review screen with quota context

**Problem:** Phase 1's review screen carries no plan context — it just lists files and page counts. With paid users in the flow, the doctor needs to know "will this fit in my quota?" *before* committing to an extract that might 429 mid-batch.
**Solution:** add a single quota-summary line above the Extract button, fed by the existing Contract B response. No structural changes to the modal. Plus an outcome handler for `quota_exceeded` results.

```
┌─────────────────────────────────────────────┐
│  Ready to extract — 3 documents, 47 pages   │
│                                              │
│    lab-report.pdf              12 pages      │
│    imaging-notes.pdf           28 pages      │
│    referral-letter.pdf          7 pages      │
│                                              │
│  412 of 2,000 left · after extract: 365     │  ← NEW (paid plans only)
│                                              │
│  [Cancel all]           [Extract 47 pages]  │
└─────────────────────────────────────────────┘
```

Render rules:
- `plan.pages_quota = null` (Enterprise) → omit the line entirely.
- `is_lifetime_cap = true` (Free Trial) → "X of 100 left (lifetime) · after extract: Y" — Phase 5 surfaces this.
- Projected post-extract balance < 0 + overage OFF → Extract button disabled, swap line for "This batch exceeds your remaining quota — enable overage or remove files."
- Projected post-extract balance < 0 + overage ON → button stays enabled, append "Y pages will be billed as overage at $0.15/page."

**Outcome handler:** when `batch-extract` returns one or more `quota_exceeded` results, open `QuotaExceededModal` (Step 8). The offending records stay in SYNCED — the user can enable overage / upgrade and re-click Extract (already-charged records replay their `(source_type, source_id)` row, not double-charge).

**Report flow parallel:** the report-generation review screen gets the same line: "14 of 20 reports left · after this: 13."

---

## Step 8 — Quota-exceeded modal + first-overage confirmation

**Problem:** when `/records/batch-extract` returns one or more `quota_exceeded` results, frontend has no UI distinguishing "upgrade plan" from "enable overage" as a next action. Separately, when overage is ON and a charge silently overflows the quota for the first time in a period, the doctor never explicitly consented to being billed for overage in that period.
**Solution:** `QuotaExceededModal` for the 429 path + `FirstOverageConfirmModal` for the silent-overflow path.

### `QuotaExceededModal` (overage OFF)
- `overage_available: true` + toggle OFF → primary CTA "Enable overage charges" (flips toggle via Contract E, retries `batch-extract`); secondary "Upgrade plan" → Checkout.
- `overage_available: false` → "Upgrade plan" only.

Records that returned `quota_exceeded` stay in SYNCED and are re-attempted on retry. `UNIQUE(source_type, source_id)` on successful ones means the retry writes no duplicate event — already-charged records respond with their original event id.

### `FirstOverageConfirmModal` (overage ON, first crossing of the period)
Spec §11 requires a one-time consent moment when a doctor first incurs overage in a period. The Step 7 review screen already shows the overage forecast ("Y pages will be billed as overage"), but a final modal makes consent explicit.

- Trigger: review screen Extract click, where the projected post-extract balance < 0 and `overage_enabled = true` and no `quota_events.overage_count > 0` row exists yet for the current period.
- Modal copy: "You've hit your monthly quota. The next 47 pages will be billed at $0.15/page on your next invoice. Continue?"
- "Continue" → fires `batch-extract` as normal.
- "Cancel" → returns to review screen; user can remove files or hit Cancel all.
- Never shown again in the same period — detection by querying Contract B's `overage` field on next render.

### Contract B response extension
Contract B (from Phase 2) gains an accrual line:
```json
"overage": { "pages_count": 37, "pages_cents": 370, "reports_count": 2, "reports_cents": 3000 }
```
Null when disabled or zero. Frontend uses non-null overage as the "first-overage already happened this period" signal — no separate flag column needed.

---

## Step 9 — Free reprocessing on parse failure

**Problem:** parse can fail for reasons the doctor didn't cause (LLM timeout, parser crash, transient GCS error). The charge already landed at extract time — without a recourse, a failed extraction permanently costs the doctor pages they never got parsed content for. Separately, at cutover there will be pre-existing FAILED `record_requests` rows created before `/quota/charge` existed — they have no corresponding `quota_events` row. A naive reprocess of one of those records would cut a fresh charge weeks after the upload, which is both a billing surprise and a quota-accounting lie (the record sat in FAILED for weeks uncharged; the Starter quota for *this* period shouldn't absorb it).
**Solution:** the charge is tied to the *record*, not the *parse attempt*. A reprocess re-submits the same `(source_type, source_id)` → `UNIQUE` hits the existing row → no second charge. Parse is re-queued against the existing record id. The reprocess path tells billing-service `replay_only=true`, meaning "return the existing event if there is one, otherwise 409 — never write a new row from this call site." Pre-cutover FAILED records (no prior event) 409 and can't be reprocessed from the UI; the frontend surfaces a clear explanation rather than a generic error.

### `replay_only` on Contract C

`POST /quota/charge` gains an optional `replay_only: bool = false` in the request body. When true, the idempotency lookup is still the first step of the transaction, but a miss returns `409 no_prior_charge` instead of falling through to a fresh quota check + write. The extract path continues to pass `replay_only=false` (default — batch-extract writes new rows for never-charged records). Only the reprocess path passes `true`.

This is a one-line branch inside the Step 2 transaction: `if existing: return existing_response(); elif replay_only: raise 409 no_prior_charge; else: … (existing quota check + write)`.

### Backend — api-backend reprocess handler

```python
# routes/records.py — POST /records/{id}/reprocess

@router.post("/records/{record_id}/reprocess")
@handle_exceptions(error_message="Failed to reprocess record")
async def reprocess_record(
    record_id: UUID,
    caller: CallerContext = Depends(get_caller),
):
    record = await records_service.fetch_owned_record(caller.caller_id, record_id)
    if record.status != "FAILED":
        raise HTTPException(409, "only FAILED records can be reprocessed")

    try:
        charge = await billing_client.charge(
            user_id=caller.caller_id,
            kind="pages",
            count=record.page_count,
            source_type="record_request",
            source_id=record.id,
            replay_only=True,   # never mint a new charge from this path
            timeout=2,
        )
    except NoPriorCharge:
        # Pre-cutover record with no quota_events row. Reprocess is blocked to
        # avoid surprise charges on weeks-old FAILED uploads. Customer-facing copy
        # handled by the frontend (see below).
        raise HTTPException(
            409,
            "record_predates_billing — cannot reprocess records uploaded before subscription launch",
        )

    await records_service.reset_record_for_reparse(record.id)  # status → SYNCED, clear parse error
    await queue_parse_job(record.id)

    return {"record_id": record.id, "event_id": charge.event_id, "reprocessed_at": ...}
```

`billing_client.charge` extended to raise `NoPriorCharge` (sibling of `QuotaExceeded` / `BillingUnavailable`) when billing-service returns 409 with `error=no_prior_charge`.

No separate reprocess rate limiter — the existing 60/min charge rate limit already governs this path. No cap on reprocess count per record (rare event; if a record fails five times, that's a system problem to investigate, not something to hard-cap).

### Frontend

`RecordRow` in the medical-records list gains a "Reprocess" action on FAILED rows. Confirmation modal:

```
┌─────────────────────────────────────────┐
│  Reprocess lab-report.pdf?              │
│                                          │
│  This won't cost additional pages —     │
│  reprocessing is always free.            │
│                                          │
│  [Cancel]              [Reprocess]       │
└─────────────────────────────────────────┘
```

On success, the record re-enters the normal parse progress flow via `useMedicalRecords` polling / WebSocket.

On `409 record_predates_billing`, swap the modal for an inline message on the row:

```
This record was uploaded before we launched subscriptions and can't be
reprocessed. Re-upload the original file to parse it under your current plan.
```

This is the only case where "Reprocess" leaves a FAILED record FAILED from the user's POV — the upload path remains the clean way forward, and a re-upload produces a new `record_request` id that charges normally against the current period. Support playbook (Phase 6) gets a canned entry for the "why can't I reprocess this old record?" question.

### Report reprocessing

Same pattern — `POST /reports/{id}/reprocess` on FAILED report generations. `(source_type="report_gen", source_id=<gen_id>)` replays against the existing ledger row with `replay_only=true`. Pre-cutover FAILED report generations are impossible (report generation didn't exist in the pre-Phase-2 product shape at the scale we're migrating), so the 409 branch is a defense-in-depth guarantee there rather than a surfaced user flow.

---

## Step 10 — Pick-a-plan route guard

**Problem:** new signups are routed to the dashboard today with no Stripe subscription — there's no step forcing them to choose a paid plan. Separately, the T&C has nowhere to be accepted — without a binding acceptance step on this surface, Phase 6's legal sign-off has nothing to hang on. Migrated users whose admin flipped `charge_exempt=false` also need to land here — they have a subscription row but no Stripe IDs.
**Solution:** route guard + `PickPlanPage` that intercepts every in-app route for users whose Contract B response shows `billing_setup_complete = false`. One branch, no Legacy carve-out.

`src/features/signup/PickPlanPage.tsx`. Trigger: Contract B's `billing_setup_complete = false` (added in Phase 2 Step 8 — true iff the user has a Stripe subscription, is `charge_exempt`, or is on a lifetime-cap plan). Migrated users default to true → no guard triggers for them. Admin flips `charge_exempt=false` → derived field goes false → next refresh satisfies the trigger → user sees the page. Plan grid → T&C checkbox → `POST /billing/checkout` (Contract D) → Stripe Checkout redirect → success returns to dashboard. No skip, no "later."

### T&C acceptance on `PickPlanPage`

Fed by Contract B's `tc.current_version`. Frontend renders the T&C link with the version string, an unchecked checkbox above the "Continue to Checkout" button, and disables the button until checked:

```
[ ] I have read and agree to the Terms & Conditions (version 2026-05-01)
    and the Privacy Policy.
    [Continue to Checkout]   ← disabled until box is checked
```

On submit, Contract D's body carries `tc_accepted_version: tc.current_version` (the value pulled from Contract B — the frontend never hand-rolls the string). Billing-service validates the exact match and writes the acceptance before creating the Stripe session.

The `billing_setup_complete` derivation lives in Phase 2 Step 8's Contract B — no schema changes needed here.

---

## Step 11 — Overage toggle card

**Problem:** Contract E has no UI — users can't opt in to billable overage without curl.
**Solution:** `OverageToggleCard` on `BillingPage`, visible only when `plan.pages_overage_cents != null` and subscription has Stripe IDs. Calls `PATCH /billing/overage` (Contract E).

Optimistic update; revert + toast on API failure.

---

## Migrated users (minimal special-casing)

Migrated users are on Starter with `charge_exempt=true`. They hit the same paid-plan branch as paying Starter subscribers — the quota check fires, 429 on exhaustion. The only `charge_exempt`-aware logic in the charge path is the overage sub-branch condition (`NOT sub.charge_exempt` — Step 2), which suppresses the Stripe usage post; every other step (in-quota check, row write, 429 on exhaustion) runs identically. Overage is additionally blocked at Contract E (no Stripe subscription for migrated users, and charge-exempt subscriptions are rejected regardless), and `PickPlanPage` doesn't render for them (`billing_setup_complete=true` via the `charge_exempt` side of the OR).

## Testing

- Unit: charge transitions (within-quota / overage / 429 / 402), `(source_type, source_id)` replay returns identical response and writes no new row, race under `SELECT FOR UPDATE` on subscription, Stripe signature verification (tampered → 400), reprocess replay produces no new event row. `charge_exempt=true` + quota exhausted → 429 (not silent overage). Stripe usage-post idempotency: same `quota_events.id` used as `idempotency_key` across retries produces exactly one Stripe usage record (verified against a mocked Stripe client), and the post job reads `pages_stripe_item_id` / `reports_stripe_item_id` off the subscription row rather than re-resolving via Stripe API. `replay_only=true` on a fresh `(source_type, source_id)` → 409 `no_prior_charge`; on an existing one → 200 with the existing event (same as `replay_only=false`).
- Integration: new signup → Stripe test-mode checkout → webhook → subscription active → upload → click Extract → `quota_events` row + parse queued. Exhausted + overage OFF → 429 + modal. Toggle on → retry → 200 + Stripe usage recorded. Parse failure → reprocess → parse re-queued, no new `quota_events` row. Pre-cutover FAILED record (seeded with no `quota_events` row) → reprocess returns 409 `record_predates_billing`; frontend surfaces the re-upload copy; no charge written. Migrated user exhausts Starter quota → 429; enabling overage is rejected at Contract E (`no_stripe_subscription`); admin flips `charge_exempt=false` → `PickPlanPage` surfaces → Checkout succeeds → next extract goes through under the new plan.
- Race: 20 concurrent `batch-extract` calls on same subscription, total > quota → exactly `quota` worth of `in_quota_count` across rows, rest 429 (or overage if enabled). `SUM(quota_events.count)` never exceeds `plan.quota` for the period.
- Reconciliation: at period end, `sum(quota_events.count WHERE kind='pages' AND period)` vs `sum(record_requests.page_count)` over the same window for *extracted* records matches exactly.
- Traceability probe: for any row in `quota_events`, `source_id` joins cleanly to a live `record_requests` or `report_gen_requests` row.

## Done when

- Paid-plan flow works end-to-end in staging: signup → checkout → upload → extract → charge → parse.
- Quota exceeded path (overage OFF) returns 429 and opens `QuotaExceededModal`.
- Overage ON path records metered usage in Stripe test-mode.
- Reprocess on a FAILED record queues parse without writing a second `quota_events` row.
- Migrated (`charge_exempt=true`) users hit the Starter quota enforcement path — no Stripe side-effects, quota exhaustion 429s correctly.

## Depends on

- Phase 2 (`plans`, `subscriptions`, `quota_events` schema + the write path and Contract B).

## Feeds

- Phase 4 (Portal endpoint, payment-failed banner, emails on webhooks).
- Phase 5 (lifetime-cap branch slotted into the same `/quota/charge` transaction).
