Skip to content

Subscription Rollout — Master Spec (v3)

End-to-end engineering rollout for the n1.care subscription model. Sits on top of the product spec sheet, 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 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 swapQuotaStatusCard + 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 · phase-2.md · phase-3.md · phase-4.md · phase-5.md · phase-6.md

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.

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.

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.

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.

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.

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.

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.

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.

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).

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.

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

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.

  • 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.

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

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)

Section titled “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 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.

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.