Subscription Rollout — Master Spec (v3)
Subscription Rollout — Master Spec (v3)
Section titled “Subscription Rollout — Master Spec (v3)”Context
Section titled “Context”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 map
Section titled “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 · phase-2.md · phase-3.md · phase-4.md · phase-5.md · phase-6.md |
Per-phase problem → solution
Section titled “Per-phase problem → solution”Phase 1
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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)
Section titled “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
Section titled “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
Section titled “Cross-cutting guarantees”Non-blocking billing calls
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “Idempotency”- Charges (
POST /quota/charge): natural idempotency viaUNIQUE(source_type, source_id)onquota_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 onn1_user_idviaUNIQUE(subscriptions.user_id)— replay returns the existing subscription. - Stripe webhooks: idempotent via
stripe_event_idstored inprocessed_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
Section titled “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
Section titled “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_idfrom 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/meor key offjwt.sub; no route accepts auser_idparameter 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/chargesource ownership. api-backend only callsbilling_client.charge(source_id=...)for records/reports it has already loaded scoped tocaller.caller_id. A foreignrecord_request_idin 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_idforwarded 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_idlookup 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_idin 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 register
Section titled “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
Section titled “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.
