Phase 3 — Paid Plan Enforcement + Stripe + Overage
Phase 3 — Paid Plan Enforcement + Stripe + Overage
Section titled “Phase 3 — Paid Plan Enforcement + Stripe + Overage”Problem → Solution
Section titled “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
Section titled “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 whenstripe_subscription_idis null). If ops decides to onboard them to paid billing, they flipcharge_exempt=falsevia 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.
- New signups pick a plan → Stripe Checkout → webhook updates the subscription row.
/quota/chargeenforces quota on paid plans underSELECT 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
Section titled “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
Section titled “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 SubscriptionItems 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.
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 SubscriptionItems 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
Section titled “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)
Section titled “Transaction (full scope after Phase 3)”-
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 setsreplay_only=trueand no existing row is found → 409no_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. -
SELECT … FOR UPDATEon the subscription row. Fetchplan,overage_enabled, Stripe status. Compute(period_start, _)viacompute_current_period(sub). -
Sub status is anything other than
active→ 402subscription_inactive. (Spec §7: declined payment is immediately read-only, no grace period — sopast_due,canceled, andincompleteall 402 here.) Migratedcharge_exempt=trueusers are alwaysactive, so they don’t 402 here. -
plan.pages_quota IS NULL(Enterprise) → Phase 2 path, unchanged. -
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 - usedcount <= 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_eventsrow withoverage_count = overage. 200 with overage details.
- Else: 429 with
{remaining, requested, overage_available}. No row written.
charge_exempt=trueusers never accrue overage — the explicit check in the condition above suppresses the overage branch entirely, regardless of whether they have a Stripe subscription item. Theoverage_enabledtoggle is hidden in the frontend for charge-exempt users (Contract B response signals this via the derivedbilling_setup_completeand the overallcharge_exemptbranching in the UI); Step 4 additionally rejects server-side attempts to enable overage on a charge-exempt subscription. -
is_lifetime_cap(free_trial): Phase 5 adds this branch. -
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’spages_stripe_item_id/reports_stripe_item_id(written by Contract E at enable-time, Step 4), then calls:stripe.SubscriptionItem.create_usage_record(id=item_id, # pages_stripe_item_id or reports_stripe_item_idquantity=event.overage_count,action="increment", # Stripe adds this to the running period totaltimestamp=int(event.occurred_at.timestamp()),idempotency_key=str(event.id), # the quota_events row id — one post per event, forevertimeout=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_eventsrow. 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 onquota_events.id.Stripe is the post-state authority;
quota_events.idas idempotency key means any retry (network blip, webhook replay, worker restart) lands on the same Stripe usage record — no double-report, no locallast_reported_countto keep in lockstep with the ledger. Retry exhaustion → ERROR log withquota_events.idfor manual replay (spec requirement from the “Non-blocking billing calls” guarantee in master).
New response shapes
Section titled “New response shapes”200 (with overage):
{ "event_id": "uuid", "in_quota_count": 3, "overage_count": 9, "overage_charge_cents": 90, "remaining": { "pages": 0, "reports": 5 } }429:
{ "error": "quota_exceeded", "remaining": { "pages": 8, "reports": 5 }, "requested": 12, "overage_available": true }402:
{ "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
Section titled “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
Section titled “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
Section titled “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}.enterpriserejected (custom contract, no Checkout path) andfree_trialrejected (Phase 5 signup path).tc_accepted_version == settings.tc_current_version(exact string match). Mismatch → 400tc_version_stalewith 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
Section titled “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:
- 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. - 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. - Create the Stripe Checkout session — outside any DB transaction (network call, non-retryable under a row lock).
timeout=5, 3 retries. Session is created withcustomer=<stripe_customer_id>so the eventualcustomer.subscription.createdwebhook (Step 5) can match the event back to the subscription row bystripe_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
Section titled “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
Section titled “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
Section titled “billing-service — PATCH /subscriptions/{user_id}/overage”Auth: N1 API key.
Body: { "enabled": true }.
- Validates: plan has non-null overage rate, sub is
activeorpast_due,stripe_subscription_id IS NOT NULL,charge_exempt = false. (No Stripe sub → no metered item to attach → 409no_stripe_subscription.charge_exempt=true→ overage would be suppressed anyway → 409charge_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 NULLorsubscriptions.reports_stripe_item_id IS NULL, provision the missing StripeSubscriptionItems for this subscription (onestripe.SubscriptionItem.createper missing item, using the plan’spages_overage_price_id/reports_overage_price_id). Persist the returned ids on thesubscriptionsrow, then setoverage_enabled = truein the same transaction. Already-populated ids are reused — no second Stripe call on a re-enable. - Disabling: column flip only (
overage_enabled = false); the storedpages_stripe_item_id/reports_stripe_item_idare 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
Section titled “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
Section titled “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:
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
Section titled “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.
# 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
Section titled “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
Section titled “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)
Section titled “QuotaExceededModal (overage OFF)”overage_available: true+ toggle OFF → primary CTA “Enable overage charges” (flips toggle via Contract E, retriesbatch-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)
Section titled “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 = trueand noquota_events.overage_count > 0row 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-extractas 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
overagefield on next render.
Contract B response extension
Section titled “Contract B response extension”Contract B (from Phase 2) gains an accrual line:
"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
Section titled “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
Section titled “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
Section titled “Backend — api-backend reprocess handler”# 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
Section titled “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 bereprocessed. 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
Section titled “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
Section titled “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
Section titled “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 checkedOn 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
Section titled “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)
Section titled “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
Section titled “Testing”- Unit: charge transitions (within-quota / overage / 429 / 402),
(source_type, source_id)replay returns identical response and writes no new row, race underSELECT FOR UPDATEon 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: samequota_events.idused asidempotency_keyacross retries produces exactly one Stripe usage record (verified against a mocked Stripe client), and the post job readspages_stripe_item_id/reports_stripe_item_idoff the subscription row rather than re-resolving via Stripe API.replay_only=trueon a fresh(source_type, source_id)→ 409no_prior_charge; on an existing one → 200 with the existing event (same asreplay_only=false). - Integration: new signup → Stripe test-mode checkout → webhook → subscription active → upload → click Extract →
quota_eventsrow + parse queued. Exhausted + overage OFF → 429 + modal. Toggle on → retry → 200 + Stripe usage recorded. Parse failure → reprocess → parse re-queued, no newquota_eventsrow. Pre-cutover FAILED record (seeded with noquota_eventsrow) → reprocess returns 409record_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 flipscharge_exempt=false→PickPlanPagesurfaces → Checkout succeeds → next extract goes through under the new plan. - Race: 20 concurrent
batch-extractcalls on same subscription, total > quota → exactlyquotaworth ofin_quota_countacross rows, rest 429 (or overage if enabled).SUM(quota_events.count)never exceedsplan.quotafor the period. - Reconciliation: at period end,
sum(quota_events.count WHERE kind='pages' AND period)vssum(record_requests.page_count)over the same window for extracted records matches exactly. - Traceability probe: for any row in
quota_events,source_idjoins cleanly to a liverecord_requestsorreport_gen_requestsrow.
Done when
Section titled “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_eventsrow. - Migrated (
charge_exempt=true) users hit the Starter quota enforcement path — no Stripe side-effects, quota exhaustion 429s correctly.
Depends on
Section titled “Depends on”- Phase 2 (
plans,subscriptions,quota_eventsschema + the write path and Contract B).
- Phase 4 (Portal endpoint, payment-failed banner, emails on webhooks).
- Phase 5 (lifetime-cap branch slotted into the same
/quota/chargetransaction).
