diff --git a/docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md b/docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md new file mode 100644 index 00000000..0da81a33 --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md @@ -0,0 +1,904 @@ +# Self-Serve Signup & Onboarding — Design Spec + +**Date:** 2026-05-05 +**Status:** Draft (revised after review-findings pass; pending user re-review) +**Author:** Michael Chihlas + Claude + +--- + +## Overview + +Open ResolutionFlow to public self-serve signup with a 14-day reverse trial on Pro, Stripe-backed billing, a sales-assist lane for Enterprise, and a hybrid onboarding flow (3-step welcome wizard + dashboard with next-step card). The current invite-code-gated registration is removed; existing pilot users transition to a permanent `subscriptions.status='complimentary'` state. **The billing layer reuses existing infrastructure** (`subscriptions` + `plan_limits` + `feature_flags` + `plan_feature_defaults` + `account_feature_overrides` + `account_invites` + `email_verification_tokens`) — this spec adds only what's missing, not parallel structures. + +--- + +## Decisions Made + +| Question | Decision | +|---|---| +| Trigger for redoing signup/onboarding | Open self-serve channel (D); must look trustworthy; must hook into payment processor cleanly | +| Trial / payment model | A + E — reverse trial (14 days, no card upfront) + sales-assist lane for Enterprise | +| Plan structure | Two self-serve tiers (Starter, Pro) per seat + sales-assist Enterprise. Defined via existing `plan_limits.plan` keys + a new `plan_billing` sibling table (Stripe IDs, prices, public catalog metadata). | +| Payment processor | Stripe with hosted Checkout; no provider abstraction | +| Auth strategy | Stay with custom auth. Extend existing email verification (auto-send on register, 7-day soft grace + dashboard wall). Add Google + Microsoft via new `oauth_identities` table; `users.password_hash` becomes nullable with explicit OAuth-only handling in login/change-password/reset. Extend existing `account_invites` (enforce email match at register, wire `EmailService` into create/bulk). | +| Signup form scope | A — minimal form (treat all signups as team-of-1) | +| Plan choice timing | X — defer; trial runs on full Pro; picker shown around day 12 and at trial-end | +| Feature gating | **Reuse existing `feature_flags` + `plan_feature_defaults` + `account_feature_overrides`.** Admin via existing `/admin/plan-limits` + `/admin/feature-flags` endpoints. No new combined `/admin/plans` surface in v1. | +| Onboarding shape | C — hybrid (3-step welcome wizard then dashboard with checklist) | +| Welcome wizard layout | V2 — narrative 3 steps (Your shop, Your PSA, Invite your team) | +| Dashboard first-run | C — topbar trial pill + single "next step" card (full checklist behind a "Show all" toggle) | +| Email verification | Soft, 7-day grace, hard wall day 7; skipped entirely for OAuth signups (provider-attested). **Reuses existing `email_verification_tokens` table + `/auth/email/send-verification` + `/auth/email/verify`.** Backend enforcement via new `require_verified_email_after_grace` dep with path allowlist (auth, profile, billing) returns 403 when grace expires unverified. Frontend `` is a UX layer over the same rule. | +| Pricing page | B — pricing + light marketing context (comparison table + testimonial slot + trust strip) | +| Trial-end conversion flow | A — quiet days 1-9, gentle nudges 10-13, hard wall day 14 with plan picker | +| Trial expiry enforcement | **Replace `deps.py:109` auto-downgrade.** Expiry is computed at request time (`status='trialing' AND current_period_end < now()`); no mutation to `plan='free'`. New backend `require_active_subscription` dep with path allowlist returns 402 when locked. | +| `is_paid` semantics | `subscriptions.is_paid` excludes `complimentary` so comp accounts don't inflate paid/MRR metrics. New `has_pro_entitlement` property covers "this account can access Pro features" (true for paid Pro + complimentary Pro + active trial). | +| Billing state surface | **Separate `GET /billing/state` endpoint** feeding a new frontend `useBillingStore`. `/auth/me` stays user-focused. | +| Teammate invite-accept | Set-password OR Google/Microsoft OAuth; email-locked **(enforced at `/auth/register` against `account_invites.email`)**; no welcome wizard for invitees. | +| Existing pilot users | All transitioned to `subscriptions.status='complimentary'` on Pro — no nags, no walls, voluntary conversion path. | +| Existing invite codes | Registration gate removed. Table preserved for historical pilots; `User.invite_code_id` retained for existing rows; not consumed at new signups. **No repurposing.** | +| Promo codes | **Deferred from v1.** Add a new `promo_codes` table later if a launch campaign needs them. | + +--- + +## Section 1 — System overview + +### What this delivers + +Public registration through `/pricing` → `/register` → `/welcome` → dashboard, with the billing substrate built almost entirely on existing infrastructure. New code is concentrated in (a) the OAuth surface, (b) Stripe-aware billing service + webhook handler, (c) the welcome wizard + dashboard redesign, and (d) the public-facing pricing page. + +### Four chunks of work + +1. **Front-of-funnel** — public `/pricing` page (B-style: comparison table + testimonial slot + trust strip), sales-lead capture form, reworked `/register` form with OAuth options. +2. **Onboarding surfaces** — 3-step welcome wizard (V2: shop → PSA selection → invite team) firing immediately after register; redesigned dashboard with topbar trial pill + single "next-step" card (C-style); 6-item checklist (Verify email → Setup shop → Run first session → Connect PSA → Invite teammate → Pick a plan). +3. **Billing integration over existing schema** — extend `plan_limits` with a sibling `plan_billing` table (Stripe IDs + public catalog metadata); seed Starter / Pro / Enterprise rows in `plan_limits`; seed `feature_flags` + `plan_feature_defaults` for the Pro/Starter gating split; add `subscriptions.status='complimentary'` value; replace `deps.py:109` trial-expiry mutation with computed checks; add a `BillingService`, Stripe webhook handler, and `require_active_subscription` dep. Reuses existing `/admin/plan-limits` and `/admin/feature-flags` admin surfaces. +4. **Auth additions** — Google + Microsoft OAuth via a new `oauth_identities` table (`users.password_hash` becomes nullable). Extend existing `email_verification_tokens` flow with auto-send on register and a 7-day soft-grace dashboard wall. Extend existing `account_invites` to enforce email match at registration and to actually send the invitation email at create-time (today only resend sends). + +### What stays the same + +- Existing JWT auth + JTI refresh rotation +- `Account` / `Team` / `User` model and the `is_super_admin` / `account_role` / `is_team_admin` permission hierarchy (with `account_role` enum `'owner' | 'admin' | 'engineer' | 'viewer'`) +- Phase 4 RLS (subscription state lives on `subscriptions`, account-scoped — RLS rules already configured for it) +- All product surfaces (FlowPilot, PSA integrations, sessions, flows) +- `/admin/plan-limits` + `/admin/feature-flags` admin endpoints (extended, not replaced) +- `/accounts/me/transfer-ownership` (existing — covers owner transfer, no longer flagged "out of scope") +- `/accounts/me/invites` and `/me/invites/{id}/resend` (extended with email send + email-match enforcement) + +### What's deprecated + +- Invite-code-as-registration-gate. The `invite_codes` table is preserved (historical foreign keys from `users.invite_code_id`); the gate is removed at `/auth/register`. +- `beta_signup.py` waitlist endpoint becomes a 307 redirect to `/register`. +- The current SOLO/TEAM split in `OnboardingChecklist` (one unified list). +- The "Check out the Script Builder" item mapped to the stale `tried_ai_assistant` key. +- Custom card-collection forms (Stripe Checkout owns this). +- The auto-downgrade-on-expired-trial logic in `deps.py:109` (replaced with non-mutating computed checks). + +### Sequencing principle + +The billing extensions (new columns, new dep, replacing the auto-downgrade) and the Stripe webhook handler are the longest pole and the most unfamiliar surface area. Build it first, ship it dark behind `SELF_SERVE_ENABLED=false`, then layer the funnel and onboarding surfaces once it's stable. Detailed phases live in the implementation plan. + +--- + +## Section 2 — Data model + +### Schema additions (new, small) + +#### `oauth_identities` + +``` +id UUID PK +user_id UUID FK users +provider VARCHAR(20) -- 'google' | 'microsoft' +provider_subject VARCHAR(255) -- provider's stable user id +provider_email_at_link VARCHAR(255) -- email reported by provider at link time +created_at, updated_at TIMESTAMP WITH TIME ZONE +UNIQUE (provider, provider_subject) +INDEX (user_id) +``` + +A user can have zero password (OAuth-only), one password, and 0+ OAuth identities. v1 ships with one identity per user (signup creates one row). Account linking is a future feature with no schema change required. + +#### `plan_billing` (sibling to `plan_limits`) + +``` +plan VARCHAR(50) PK FK plan_limits.plan +display_name VARCHAR(255) NOT NULL +description TEXT NULL +monthly_price_cents INTEGER NULL -- nullable for Enterprise (custom) +annual_price_cents INTEGER NULL +stripe_product_id VARCHAR(255) NULL +stripe_monthly_price_id VARCHAR(255) NULL +stripe_annual_price_id VARCHAR(255) NULL +is_public BOOLEAN NOT NULL DEFAULT TRUE +is_archived BOOLEAN NOT NULL DEFAULT FALSE +sort_order INTEGER NOT NULL DEFAULT 0 +created_at, updated_at TIMESTAMP WITH TIME ZONE +``` + +`plan_limits.plan` stays the canonical plan key. `plan_billing` carries the Stripe + public-catalog metadata. Joined into the existing `/admin/plan-limits` admin endpoint via the response schema (single PUT updates both tables in one transaction). + +#### `sales_leads` + +``` +id UUID PK +email VARCHAR(255) INDEXED +name VARCHAR(255) +company VARCHAR(255) +team_size VARCHAR(20) -- range string from form +message TEXT +source VARCHAR(50) -- 'pricing_page' | 'register_footer' | etc. +posthog_distinct_id VARCHAR(255) NULL +status VARCHAR(20) DEFAULT 'new' -- 'new' | 'contacted' | 'closed' +created_at, updated_at +``` + +Global table. No RLS. + +#### `stripe_events` + +Webhook idempotency log. Global table. + +``` +id VARCHAR(255) PK -- Stripe event id +event_type VARCHAR(100) INDEXED +processed_at TIMESTAMP WITH TIME ZONE +payload_excerpt JSONB +``` + +### Modifications to existing tables + +#### `subscriptions` — extend the status enum + +- New status value: `'complimentary'`. Status enum effectively becomes `'active' | 'trialing' | 'past_due' | 'canceled' | 'incomplete' | 'complimentary'`. The column is `String(50)` so no schema migration is required for the value itself; we update the value-level checks only. +- `Subscription.is_active` already returns `True` for `('active', 'trialing')` — extend to include `'complimentary'`. +- `Subscription.is_paid` (currently `plan in ('pro', 'team')`) → narrow to `plan in ('pro', 'team') AND status NOT IN ('complimentary',)`. Used for revenue / paid-customer / MRR calculations only. +- New `Subscription.has_pro_entitlement` property: returns True for `(plan='pro' AND status IN ('active', 'complimentary'))` OR `(status='trialing' AND current_period_end > now())`. Used for "can this account access Pro features." + +These are model-level Python property changes plus tests; the underlying column type doesn't change. + +#### `users` — additions + +- `email_verified_at` already exists. No add. Email-verification flow uses it. +- `password_hash` — **change `nullable=False` → `nullable=True`** to support OAuth-only users. Migration sets nullable; no data backfill needed (existing rows all have hashes). +- `role_at_signup VARCHAR(50) NULL` — `'owner' | 'lead_tech' | 'tech' | 'other'` (welcome-wizard Step 1 captures this). + +The existing `users.onboarding_dismissed` field stays. **Add a new `users.onboarding_step_completed INTEGER NULL`** that tracks the highest wizard step the user has either completed or explicitly skipped (1, 2, or 3; NULL = haven't started). This is the only new column needed beyond `role_at_signup` and resolves the per-step skip ambiguity that derived data couldn't represent. + +Wizard state model: + +- User clicks **Continue** on a step → `onboarding_step_completed = step_number`. Step's data fields are written (e.g., Step 1 writes `users.role_at_signup` + `accounts.team_size_bucket`). +- User clicks **Skip** on a step → `onboarding_step_completed = step_number`. Step's data fields stay NULL. +- User clicks **Skip the rest** on any step → `users.onboarding_dismissed = TRUE` (whatever step they were on stays as `onboarding_step_completed = step_number - 1`). +- Wizard is "done" when `onboarding_dismissed = TRUE` OR `onboarding_step_completed >= 3`. +- `/welcome` redirect logic: if done, go to `/`; otherwise go to `/welcome/step-{onboarding_step_completed + 1 or 1}`. + +This makes "I intentionally skipped inviting teammates" representable separately from "I haven't reached Step 3 yet." + +#### `accounts` — additions for wizard data + +`accounts.name` (existing, `String(255) NOT NULL`) is reused for the wizard's "Company name" field — the wizard updates this row rather than a new column. Today `accounts.name` is populated at register-time from the user's input or a sensible default; the wizard lets the user correct it. + +New columns: + +- `team_size_bucket VARCHAR(20) NULL` — `'1-2' | '3-5' | '6-10' | '11-25' | '26+'` +- `primary_psa VARCHAR(20) NULL` — `'connectwise' | 'autotask' | 'halopsa' | 'none'` + +No billing state on `accounts` — it lives on `subscriptions`. + +#### `account_invites` — small additions + +- `revoked_at TIMESTAMP WITH TIME ZONE NULL` — distinguishes revoked from used. Current model has only `used_at`; revoke (resend handler at `accounts.py:323`) currently deletes the row. Add `revoked_at` + change resend to soft-revoke for audit trail. +- (Optional) `email_sent_at TIMESTAMP WITH TIME ZONE NULL` — track that the invite email was actually sent (today, only resend sends; create does not). + +`AccountInvite.is_used` and `is_valid` properties extend to consider `revoked_at`. + +### Migrations + +Single Alembic chain — manual revisions per Lesson 77. Multi-head heads on `main` (`070`, `c0f3a4b7e91d`, `024`) currently coexist; the new chain branches from the most recent and merges via `alembic upgrade heads` (plural). + +1. `add_oauth_identities.py` — new table. +2. `users_password_hash_nullable.py` — alter to nullable. +3. `users_add_role_at_signup_and_onboarding_step.py` — add `role_at_signup` and `onboarding_step_completed` columns. +4. `accounts_add_wizard_columns.py` — add `team_size_bucket`, `primary_psa`. (`accounts.name` already exists; wizard writes to it.) +5. `account_invites_add_revoked_at_and_email_sent_at.py` — add columns. +6. `add_plan_billing.py` — new sibling table. Seeds Starter / Pro / Enterprise rows **with `stripe_product_id` / `stripe_*_price_id` left NULL**. Existing `plan_limits` rows already exist for `'free' / 'pro' / 'team'`; this migration aligns keys (`'starter' | 'pro' | 'enterprise'` if we rename, OR keep `'free' / 'pro' / 'team'` and treat `'free'` as the floor — open risk #14 captures the decision). Stripe IDs are populated **out-of-band** per environment via either the existing `/admin/plan-limits` PUT (extended to accept Stripe fields) or a one-off `python -m scripts.sync_stripe_plan_ids` admin command driven by env vars. **Migrations stay environment-agnostic** — they don't read live mode vs. test mode IDs. +7. `seed_pro_starter_feature_flags.py` — register feature keys (`psa_integration`, `escalation_mode`, `script_builder`, `analytics_dashboards`, `knowledge_flywheel`, `team_admin_full`, `monthly_sessions` quantitative, `seats` quantitative, `sso`, `audit_log`) in `feature_flags`; populate `plan_feature_defaults` per the Pro/Starter split. +8. `subscriptions_pilot_complimentary_backfill.py` — `UPDATE subscriptions SET status='complimentary', plan='pro' WHERE status NOT IN ('canceled')` for accounts that exist as of cutover. Single statement; ≤ 100 rows expected. +9. `add_sales_leads_and_stripe_events.py` — two new tables. + +Forward-only. No down-migrations for the data backfills (step 8) — the original status values per account are not preserved. + +### RLS notes + +- `oauth_identities` is account-adjacent (joined via `user_id`), but RLS on `users` is admin-DB-only (per `deps.py` `get_current_user` uses `get_admin_db`). Treat `oauth_identities` the same — no per-tenant RLS policy; queries use admin session. Verify against current `users` table policy before merging. +- `plan_billing` is global (joins `plan_limits.plan`, also global). No RLS. +- `sales_leads`, `stripe_events` are global. No RLS. +- `account_invites` already has its policy (account-scoped). No change. +- `subscriptions` already has its policy. No change to schema means no RLS revision. + +### Index notes + +- `oauth_identities (provider, provider_subject)` UNIQUE — the OAuth callback's primary lookup. +- `oauth_identities (user_id)` — list a user's identities. +- `account_invites (revoked_at)` — partial filter for active-invites queries (`WHERE accepted_by_id IS NULL AND revoked_at IS NULL`). + +--- + +## Section 3 — Funnel walkthrough + +### 1. Acquisition — `/pricing` (public) + +New route. B-style page: hero (one-liner + reverse-trial reassurance), three plan cards (Starter / Pro recommended / Enterprise), comparison table, testimonial slot (placeholder copy until a real one lands), trust strip ("SOC2 in progress · Stripe billing · GDPR DPA available"). Plan card data sourced from `plan_billing` filtered by `is_public=TRUE AND is_archived=FALSE`. + +- **Pro/Starter cards** → "Start free trial" → `/register?plan=pro` (or `?plan=starter`). Query param remembered through OAuth round-trip. +- **Enterprise card** → "Talk to sales" → `/contact-sales` → POST `/sales-leads` → confirmation page with Calendly link in the email. +- Existing `LandingPage.tsx` gets a "See pricing" CTA pointing here. + +### 2. Registration — `/register` (public, redesigned) + +Three sign-up paths on one page: + +- **Google sign-in** (primary button at top) → OAuth round-trip → `/auth/google/callback`. Backend creates a User if first time (`oauth_identities` row + Account + Subscription on Pro trial via `BillingService.start_trial`), marks `email_verified_at = now()` (provider-attested), redirects to `/welcome`. +- **Microsoft sign-in** (button) → same flow with `provider='microsoft'`. +- **Email + password** → POST `/auth/register`. Backend creates User (with `password_hash` set) + Account, calls `BillingService.start_trial`, sends verification email via existing `EmailService.send_email_verification_email` (auto-send is added; today the user has to click a button), returns JWT, frontend redirects to `/welcome`. + +Form fields: full name, work email, password (10+ chars, complexity rules per existing `UserCreate.password_complexity` validator). The current `invite_code` field on `UserCreate` is **removed at the registration gate** — public signups don't need one. The `account_invite_code` field is **kept** for the teammate-accept flow (see step 5b below). + +**Critical fix flagged in review:** registration with `account_invite_code` must enforce `user_data.email == account_invites.email` (today this is not enforced at `/auth/register`). The check happens in the register handler before the User is created; mismatch returns 400 with `{"error": "invite_email_mismatch"}`. + +### 3. Welcome wizard — `/welcome` (authed) + +Dedicated routes: `/welcome/step-1` (Your shop), `/welcome/step-2` (Your PSA), `/welcome/step-3` (Invite team). `/welcome` itself redirects to the lowest-numbered incomplete step. Each step persists immediately (PATCH endpoints — see Appendix A) so refreshes don't lose data and "Skip the rest" lands cleanly. + +- **Step 1 — Your shop**: company name (pre-filled from existing `accounts.name`, editable), team size bucket, your role. Saves to `accounts.name`, `accounts.team_size_bucket`, `users.role_at_signup`. +- **Step 2 — Your PSA**: PSA selection only. Saves to `accounts.primary_psa`. Quiet "Connect now" link → `/account/integrations` (out of wizard); default action is **Continue**. No API key entry inside the wizard. +- **Step 3 — Invite your team**: up to 3 email fields visible with "+ Add another" link; each invite defaults to "Tech" role; fully skippable. POSTs to a new `POST /accounts/me/invites/bulk` (thin wrapper around the existing single-create) **and sends invite emails per row**. The wizard's "Tech" UI label maps to `account_invites.role = 'engineer'` in the DB; "Viewer" UI label maps to `'viewer'` (per the existing CHECK constraint). + +**Critical fix flagged in review:** today, `POST /accounts/me/invites` (`accounts.py:257`) creates the row but does NOT send the email — only `/me/invites/{id}/resend` sends. The new flow wires `EmailService.send_account_invite_email` (existing method at `core/email.py:125`) into both create and bulk paths and stamps `email_sent_at` on success. + +Skip behavior: "Skip" on a step advances `users.onboarding_step_completed` (recording that the user saw and chose to skip that step). A separate "Skip the rest, take me to dashboard" link sets `users.onboarding_dismissed=TRUE` and redirects to `/`. Wizard is "done" when `onboarding_dismissed=TRUE` OR `onboarding_step_completed >= 3`. Auth-store reads this state on app load; `/welcome` redirects to the next incomplete step or to `/` if done. + +**Invited teammate variant:** invitee's email link goes to a frontend `/accept-invite?code=…` route that posts to `/auth/register` with `account_invite_code` (per the existing `UserCreate` schema). They land on `/?welcome=teammate` instead of the wizard, and get a brief "Welcome to {company}'s ResolutionFlow" toast. Re-running the wizard on already-onboarded users is suppressed via `users.onboarding_dismissed` OR derived data presence. + +### 4. Dashboard — `/` (authed, redesigned) + +- **Topbar pill** in `AppLayout` renders based on `subscriptions.status` and `current_period_end`: + - `trialing` AND `current_period_end > now()`: "Pro trial · Nd" — blue, amber when ≤3d remaining, red when ≤1d. + - `trialing` AND `current_period_end <= now()`: "Trial expired — pick a plan" (the locked state — no mutation has occurred at the DB level, just rendered differently). + - `active`: tier name only ("Pro" / "Starter") — no urgency. + - `complimentary`: "Complimentary Pro" — friendly tag, no CTA. + - `past_due`: "Payment failed — update card" — clickable, routes to `/account/billing`. + - `canceled`: pill becomes a "Reactivate" CTA. +- **Next-step card** sits below the topbar. "Show all setup steps" link expands the full 6-item list inline. +- **Email-verification banner** (when `users.email_verified_at IS NULL`): always-visible thin bar above the next-step card with a "Resend" link (POSTs to existing `/auth/email/send-verification`). On day 7 unverified, the dashboard route renders `` instead of normal content. + +Checklist items (same for everyone — no SOLO/TEAM split): + +1. **Verify your email** — auto-completes on link click; hidden if signed up via OAuth. +2. **Set up your shop** — completes when `users.onboarding_step_completed >= 1`. +3. **Run your first FlowPilot session** — the wedge. Highlighted as the headline action when prior items are complete. +4. **Connect your PSA** — auto-completes when first PSA connection saved. Pre-fills the provider based on welcome wizard selection. +5. **Invite a teammate** — auto-completes when first invitation is sent. +6. **Pick a plan** — appears earlier with low emphasis; turns urgent at ≤3 days remaining in trial. + +The stale `tried_ai_assistant` / "Check out the Script Builder" item is dropped entirely. + +### 5. Email verification — existing endpoints, new gating + +- `POST /auth/email/send-verification` (existing, `auth.py:621`) is auto-called by `/auth/register` — today the user has to click a button. +- `POST /auth/email/verify` (existing, `auth.py:662`) consumes the token and sets `users.email_verified_at`. +- The frontend `/verify-email?token=…` route calls the existing endpoint and shows a success or error state. +- New: a frontend gating layer (``) wraps the dashboard route. Day 1-6 unverified shows the soft banner; day 7+ unverified renders ``. +- **Backend enforcement** via the new `require_verified_email_after_grace` dep (Section 4). The frontend wall is UX; the backend dep prevents direct API access by an unverified user past the 7-day grace. Mounted on every protected router; allowlists `/auth/*` (logout, verify, send-verification, password change), `/users/me`, and `/billing/*` so the user can still log out, verify, manage their profile, and convert to paid. + +No new endpoints, no new column. One new backend dep. + +### 6. Trial-end — Days 10-14 + +- **Day 10**: in-app modal once ("Your trial ends in 4 days. Pick a plan to keep going."). Fired by `useTrialBanner` hook reading from `useBillingStore` (which polls `GET /billing/state`); per-user dismiss recorded in localStorage. Email day 10 + day 13 (`EmailService.send_trial_ending`). +- **Day 14**: when `subscriptions.status='trialing'` AND `current_period_end < now()`, the dashboard route renders `` with the plan picker (Starter / Pro radio + seat count input). **No DB mutation occurs** — the lockout is computed at request time. Past sessions remain visible read-only for 30 days after `current_period_end` — computed at render time as `current_period_end + INTERVAL '30 days' < now()`. After that window, sessions are still in the database (no destructive action) but the dashboard hides them behind the wall until billing is added. + +### 7. Plan picker → Stripe Checkout — `/account/billing/select-plan` (authed) + +User picks Starter/Pro + seat count → POST `/billing/checkout-session` → backend calls `stripe.checkout.sessions.create` with: + +- `customer_email` from User +- `line_items` (price_id from `plan_billing` × quantity = seats) +- `mode='subscription'` +- `subscription_data.trial_end = current_period_end` if still in trial (Stripe takes over the trial countdown) +- `success_url=/account/billing?success=1`, `cancel_url=/account/billing/select-plan` + +Frontend redirects to Stripe-hosted Checkout. Stripe `checkout.session.completed` webhook → backend updates `subscriptions.status='active'`, sets `stripe_subscription_id`, `stripe_price_id`, refreshes `current_period_start/end` from the Stripe subscription, sets `seat_limit`. Idempotency via `stripe_events.id`. + +Success URL renders dashboard with "Pro active 🎉" toast. + +### 8. Past-due / dunning + +Stripe `invoice.payment_failed` webhook → `subscriptions.status='past_due'`. Topbar pill flips to "Payment failed — update card" linking to `/account/billing`, which uses Stripe's Customer Portal for card updates and cancellation. Dashboard remains accessible during the dunning window (Stripe default: 4 retries over 3 weeks). Account locks via `require_active_subscription` only at `canceled`. + +### 9. Sales lead — `/contact-sales` (public) + +Form posts to `/sales-leads` → creates row + sends email to `sales@resolutionflow.com` + emits PostHog event. Confirmation page: "Thanks — we'll reach out within 1 business day. Want to skip ahead? [Calendly link]." The Calendly link is a config string, not a calendar integration in v1. + +### 10. Owner transfer (existing — noted) + +Owner transfer is supported via the existing `POST /accounts/me/transfer-ownership` (`accounts.py:150`). The pricing-page Enterprise tier and the Account → Team page in the redesigned dashboard surface this for owners who need to hand off the account. **Not flagged as out-of-scope risk** as it was in the prior draft. + +--- + +## Section 4 — Billing substrate + Stripe integration + +### `app.services.billing.BillingService` + +Single billing module — not a polymorphic provider abstraction. + +```python +class BillingService: + @staticmethod + async def start_trial(db, account: Account) -> Subscription: + """Creates or updates the Subscription row for a new account. + Sets plan='pro', status='trialing', current_period_end=now()+14d. + Called from /auth/register (email path) and OAuth-callback flows. + No Stripe API call yet — Stripe Customer is created lazily at first + checkout.""" + + @staticmethod + async def create_checkout_session(db, account, plan, seats, billing_interval) -> str: + """Returns the Stripe Checkout URL. Creates Stripe Customer if missing + (stores stripe_customer_id on the **Account** row — existing column at + accounts.stripe_customer_id), then builds checkout.sessions.create + with line_items, mode='subscription', subscription_data.trial_end if + still within local trial, success/cancel URLs. Subscription row is + updated by the webhook handler with stripe_subscription_id and + stripe_price_id once checkout completes.""" + + @staticmethod + async def apply_subscription_event(db, event_type: str, payload: dict) -> None: + """Single entry point for every Stripe webhook that mutates subscription + state. Pure function of (event_type, payload) -> DB writes. Called from + the webhook handler after signature verification + idempotency check.""" + + @staticmethod + async def open_customer_portal(account) -> str: + """Returns Stripe-hosted Customer Portal URL for card updates and + cancellation.""" + + @staticmethod + async def get_billing_state(db, account: Account) -> BillingStateResponse: + """Returns the full billing snapshot for /billing/state — subscription + status, plan, plan_billing metadata, plan_limits values, and the + flattened effective feature flags (defaults overridden by + account_feature_overrides).""" +``` + +`account_id` is the canonical local key; Stripe is the canonical remote state; the webhook handler is the bridge. + +### Replacing the trial auto-downgrade + +The existing logic in `deps.py:81-129` mutates `subscriptions` on every request when a trial expires: + +```python +# CURRENT (to be removed): +if subscription.status == "trialing" and subscription.current_period_end < now(): + subscription.plan = "free" + subscription.status = "active" + subscription.current_period_end = None + subscription.current_period_start = None + await db.commit() +``` + +**Replace this entire block with no-op.** Trial expiry becomes a *computed* state. The data stays as `status='trialing'`, `current_period_end` in the past — readable, observable, idempotent. The new `require_active_subscription` dep enforces the lockout. + +If we ever want an explicit `'expired'` status (for analytics observability), it can be added later without changing the semantic of "trialing + past current_period_end = locked." + +### New backend dep — `require_active_subscription` + +```python +_SUBSCRIPTION_GUARD_ALLOWLIST = { + # auth & profile + "/api/v1/auth/me", + "/api/v1/auth/logout", + "/api/v1/auth/password/change", + "/api/v1/auth/email/send-verification", + "/api/v1/auth/email/verify", + # billing surfaces + "/api/v1/billing/state", + "/api/v1/billing/checkout-session", + "/api/v1/billing/portal-session", + # users own profile + "/api/v1/users/me", + "/api/v1/users/me/onboarding-step", + # read-only history (pattern match: /sessions and /trees in GET only) +} + +async def require_active_subscription( + request: Request, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_admin_db), +) -> Subscription: + """Enforces 'this account currently has access.' Mounted on routers that + require Pro entitlement. Returns the Subscription row when allowed; raises + 402 with structured payload when locked.""" + + if request.url.path in _SUBSCRIPTION_GUARD_ALLOWLIST: + return None # bypass + + sub = await _get_subscription_for_account(db, current_user.account_id) + if not sub: + raise HTTPException(402, detail={"error": "no_subscription"}) + + is_live = ( + sub.status in ("active", "complimentary") + or ( + sub.status == "trialing" + and sub.current_period_end is not None + and sub.current_period_end > datetime.now(timezone.utc) + ) + or sub.status == "past_due" # dunning grace — Stripe retries + ) + if not is_live: + raise HTTPException( + status_code=402, + detail={ + "error": "subscription_inactive", + "status": sub.status, + "plan": sub.plan, + "current_period_end": sub.current_period_end.isoformat() if sub.current_period_end else None, + "upgrade_url": "/account/billing/select-plan", + }, + ) + + return sub +``` + +Mounted on every router under `/api/v1/` *except* the explicit allowlist. GET endpoints for past sessions/trees during the 30-day read-only post-expiry window need a softer variant — see Section 3 step 6 for the read-only contract. Implementation plan will identify each protected endpoint specifically. + +### New backend dep — `require_verified_email_after_grace` + +Mirror of `require_active_subscription`, but for email verification. The frontend `` is a UX layer; this dep is the security layer that prevents an unverified user from bypassing the wall by hitting product APIs directly. + +```python +_EMAIL_VERIFICATION_ALLOWLIST = { + # auth & session + "/api/v1/auth/me", + "/api/v1/auth/logout", + "/api/v1/auth/email/send-verification", + "/api/v1/auth/email/verify", + "/api/v1/auth/password/change", + # users own profile + "/api/v1/users/me", + # billing — let user manage subscription even if email unverified + "/api/v1/billing/state", + "/api/v1/billing/checkout-session", + "/api/v1/billing/portal-session", +} + +VERIFICATION_GRACE_DAYS = 7 + +async def require_verified_email_after_grace( + request: Request, + current_user: User = Depends(get_current_active_user), +) -> None: + """Enforces 'this user has verified their email, OR is still inside the + 7-day grace from account creation.' OAuth signups bypass cleanly because + /auth/google/callback and /auth/microsoft/callback set + users.email_verified_at = now() (provider-attested). + Mounted on every protected router *except* the explicit allowlist.""" + + if request.url.path in _EMAIL_VERIFICATION_ALLOWLIST: + return + + if current_user.email_verified_at is not None: + return + + grace_ends = current_user.created_at + timedelta(days=VERIFICATION_GRACE_DAYS) + if datetime.now(timezone.utc) < grace_ends: + return # still inside grace + + raise HTTPException( + status_code=403, + detail={ + "error": "email_not_verified", + "grace_ended_at": grace_ends.isoformat(), + "resend_url": "/api/v1/auth/email/send-verification", + }, + ) +``` + +Differs from `require_active_subscription` in three ways: + +- **403 (Forbidden) rather than 402 (Payment Required)** — verification is identity, not billing. Lets the frontend interceptor route to a verification CTA, distinct from the upgrade CTA. +- **No DB read** — uses fields already on the `current_user` row from `get_current_active_user`. Cheap. +- **Allowlist includes `/billing/*`** — an unverified user past day 7 should still be able to convert to paid (verification gates feature use, not billing). The verification banner persists into Checkout if needed. + +The two guards compose: most routers depend on **both** `require_active_subscription` AND `require_verified_email_after_grace`. The implementation plan will identify each protected router specifically; both guards are non-optional for product surfaces. + +### Stripe webhook handler — `POST /api/v1/webhooks/stripe` + +A stub already exists at `app/api/endpoints/webhooks.py` with signature verification + an early-out when `settings.stripe_enabled=False`. This work extends the stub — does not replace it — by wiring concrete event handlers, idempotency tracking, and `BillingService.apply_subscription_event` integration. + +- Public endpoint; signature verification is the only gate. +- Reads `Stripe-Signature` header → `stripe.Webhook.construct_event(payload, sig, STRIPE_WEBHOOK_SECRET)` → 400 on mismatch. +- **Idempotency**: every event recorded in `stripe_events` keyed by Stripe's event id. If the row exists, return 200 immediately. +- Uses `_admin_session_factory()` — no `current_account_id` is set during webhook processing (Phase 4 RLS pattern). +- **Replay protection**: Stripe signatures embed a timestamp; reject if older than 5 min. + +Events handled: + +| Event | Action | +|---|---| +| `checkout.session.completed` | Activate: `subscriptions.status='active'`, set `subscriptions.stripe_subscription_id`, `subscriptions.stripe_price_id`, `subscriptions.current_period_start/end`, `subscriptions.seat_limit` from session line_items. (`accounts.stripe_customer_id` was set earlier at `create_checkout_session` time.) | +| `customer.subscription.updated` | Reflect plan changes / period transitions / seat updates | +| `customer.subscription.deleted` | `status='canceled'`, lock via `require_active_subscription` | +| `invoice.payment_failed` | `status='past_due'` | +| `invoice.payment_succeeded` | Confirm `status='active'` after dunning recovery | +| Other | Log and ack 200 | + +### Backend feature-gate dep — `require_feature` + +Reads from the existing 3-table chain (no new tables). **`require_feature` internally composes with `require_active_subscription`** — feature gating without subscription gating would let canceled/expired-trial accounts pass feature checks. They are not independent. + +```python +async def require_feature(flag_key: str): + async def _dep( + sub: Subscription = Depends(require_active_subscription), + user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_admin_db), + ) -> None: + # require_active_subscription has already verified the account is live; + # sub is the live Subscription row. Now check the feature flag. + flag = await _resolve_flag(db, user.account_id, sub.plan, flag_key) + if not flag.enabled: + raise HTTPException( + status_code=402, + detail={ + "error": "feature_not_in_plan", + "feature": flag_key, + "current_plan": sub.plan, + "upgrade_url": "/account/billing/select-plan", + }, + ) + return _dep + + +async def _resolve_flag(db, account_id, plan_key, flag_key): + """Resolve effective feature flag value: + 1. account_feature_overrides for (account_id, flag_key) -> if exists, use that + 2. else plan_feature_defaults for (plan, flag_key) -> use that + 3. else default disabled + """ +``` + +Used as `Depends(require_feature("psa_integration"))` on PSA endpoints, Escalation Mode, Script Builder, Analytics endpoints. The 402-with-payload pattern lets the frontend route the user to `/account/billing/select-plan`. + +For quantitative limits (sessions per month, AI builds): existing `plan_limits` columns (`max_sessions_per_month`, `max_ai_builds_per_month`, etc.) already cover these. Use a sibling helper: + +```python +async def require_within_limit(field: str): + """e.g., field='max_sessions_per_month' — checks current usage against + the resolved plan_limits value, with account-override consulting via + /admin/plan-limits/account-overrides table.""" +``` + +This is closer to the existing `get_user_plan_limits` helper (`core/subscriptions.py`) and reuses that path. + +### Caching strategy + +- Subscription row, plan_limits row, plan_billing row, and resolved feature flag map: cached in `app.state.billing_cache` keyed by `account_id`. TTL 5 minutes. +- Explicit invalidation triggers: + - Stripe webhook handler when `subscriptions` state changes (account-keyed invalidation). + - `/admin/plan-limits` PUT (invalidate **all** accounts on that plan, since plan-wide limits / billing fields changed). + - `/admin/plan-limits/account-overrides` POST/PUT/DELETE (account-keyed). + - `/admin/feature-flags` PUT/DELETE on flag definitions (full-cache flush). + - `/admin/feature-flags/plan-defaults` PUT (invalidate **all** accounts on that plan). + - `/admin/feature-flags/account-overrides` POST/DELETE (account-keyed). +- For Railway multi-worker: per-process cache. The 5-minute TTL bounds inconsistency. Acceptable for v1; revisit with Redis pubsub if we run > 2 workers. + +### Frontend — `useBillingStore` + `GET /billing/state` + +``` +GET /billing/state -> { + subscription: { + status: 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'complimentary', + plan: 'starter' | 'pro' | 'enterprise', + current_period_start: ISODateTime | null, + current_period_end: ISODateTime | null, + cancel_at_period_end: boolean, + seat_limit: number | null, + has_pro_entitlement: boolean, + is_paid: boolean, + }, + plan_billing: { + display_name: string, + monthly_price_cents: number | null, + annual_price_cents: number | null, + }, + plan_limits: { + max_trees, max_sessions_per_month, max_users, ...all current PlanLimits fields + }, + enabled_features: Record, -- flat resolved map +} +``` + +Frontend hooks: + +- `useFeature(key: string): boolean` — reads `enabled_features[key]` from `useBillingStore`. +- `useFeatureLimit(key): { used, limit, percentage, isAtLimit }` — combines `plan_limits[key]` with a lazy `/usage/{key}` count. +- `useTrialBanner(): { stage: 'pristine' | 'warning' | 'urgent' | 'expired', daysRemaining }` — derived from `subscription.status` + `current_period_end`. +- `}>...children` — wrapper for whole-section gating. + +`useBillingStore` is a Zustand store with: +- Initial fetch on auth-store login. +- Refetch on webhook-driven server-sent events (or, for v1, polling every 60s while the dashboard is mounted). +- Manual `refetchBilling()` exposed for use after Stripe Checkout success-redirect. + +`/auth/me` and `UserResponse` stay user-focused — no billing data embedded. + +### Admin UI — reuse existing surfaces + +- `/admin/plan-limits` — extended to also surface `plan_billing` fields in the editor (single PUT round-trips both tables in one transaction). +- `/admin/feature-flags` — unchanged. Toggling a flag's `plan_feature_defaults` enables/disables the feature for that plan tier. +- `/admin/feature-flags/account-overrides` — unchanged. Used for sales-negotiated grants, comp accounts, kill-switching a feature for one customer. + +No new combined `/admin/plans` admin page in v1. + +### Failure modes + +| Scenario | Outcome | +|---|---| +| User abandons Stripe Checkout | No webhook fires; `subscriptions.status` stays `trialing`; trial-end wall fires normally on day 14 via `require_active_subscription` | +| Webhook arrives before app reconciles local state | `stripe_events` idempotency makes this safe | +| Webhook secret rotated | Old webhook attempts 400 until env var redeployed | +| Concurrent webhooks for the same subscription | DB row-level locks on the `subscriptions` row serialize updates; idempotency check is the first read in the transaction | +| Stripe outage during checkout | `BillingService.create_checkout_session` raises; frontend shows "Couldn't start checkout — try again" toast | +| Account on `complimentary` accidentally hits a webhook (e.g., admin manually attached a Stripe customer) | Handler transitions to whatever Stripe says; admin can revert via DB or via `/admin/plan-limits/account-overrides` if needed | +| OAuth-only user attempts `/auth/login` (password) | Login endpoint rejects with 400 `{"error": "use_oauth_provider", "providers": ["google"]}` so frontend can route them to the right button | +| OAuth-only user attempts `/auth/password/change` | Endpoint rejects with 400 — must set initial password via a separate `/auth/password/set-initial` flow (out of scope for v1; OAuth users stay OAuth-only) | +| OAuth-only user requests password reset | Reset email is suppressed; user is shown "Sign in with {provider}" instead | + +--- + +## Section 5 — Migration plan + +### Pre-deploy: Stripe configuration + +Manual setup, separate per environment. + +**Status note (2026-05-05):** Stripe **test mode** Products + Prices + webhook endpoint + test env vars in Railway are already configured. Live-mode setup remains for cutover. + +For each environment: + +1. **Stripe Dashboard**: + - Create Products: `ResolutionFlow Starter`, `ResolutionFlow Pro`, `ResolutionFlow Enterprise` (no public price). + - Create Prices for Starter/Pro: monthly + annual recurring. + - Enable **Customer Portal** with: update payment method, cancel subscription, view invoices. Disable plan-switching from the portal. + - Register webhook endpoint at `https://api.resolutionflow.com/api/v1/webhooks/stripe` with the events listed in Section 4. Save the signing secret. +2. **Railway env vars** (per environment): + - `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PUBLISHABLE_KEY` (frontend; needs `ARG`+`ENV` in `frontend/Dockerfile` per Lesson 60). + +### Schema migration + +Manual revisions per Lesson 77. New chain branches from the most recent of `main`'s heads (`070`, `c0f3a4b7e91d`, `024`) and merges via `alembic upgrade heads`. Migration filenames are listed in Section 2. + +Forward-only. + +### Pilot user transition + +- Migration step 8 sets `subscriptions.status='complimentary'`, `plan='pro'` for all existing accounts (≤ 100 rows). Single statement. +- **Outbound communication**: a single email from `EmailService.send_complimentary_account_announcement` to every pilot user 1-2 days before cutover: + > *"We're opening ResolutionFlow up for new signups. Your account is now a Complimentary Pro account — nothing changes for you. You'll see a small "Complimentary Pro" tag in the app instead of any trial pill. Thanks for piloting."* +- **In-app first-login toast** (optional; ship without if scope tightens): per-browser via localStorage key `rf-complimentary-announcement-seen-{user_id}`. + +### Existing invite-code disposition + +- `invite_codes` table preserved. +- `User.invite_code_id` foreign keys preserved for historical pilots. +- Registration handler (`/auth/register`) drops the invite_code-required gate. The `UserCreate.invite_code` field stays in the schema for backward compatibility but is ignored at registration. No new validations against the `invite_codes` table at signup. +- No promo-code repurposing. Invite codes simply stop being consumed. + +### Beta-signup deprecation + +- `beta_signup.py` endpoint stays mounted but returns 307 redirect to `/register?from=beta`. +- Existing waitlist rows: send a "we've launched — come on in" email with a one-time `from=beta` link. Preserve the table; do not drop. + +### Deploy ordering — dark launch then cutover + +1. **Backend deploy with `SELF_SERVE_ENABLED=false`**: all new endpoints exist (webhook handler, billing, OAuth callbacks, sales-leads, bulk invite, billing/state). `/auth/register` retains the existing invite-code requirement. `/pricing` returns 404. Webhook handler is **live**. +2. **Frontend deploy with `VITE_SELF_SERVE_ENABLED=false`**: new surfaces are routed but hidden behind the flag. +3. **Stripe live-mode configuration in prod** (manual, ~30 min). +4. **Internal validation (1-2 days)**: founder + any teammates use a per-email allowlist to enable self-serve for their accounts only. Tests cover: email signup, OAuth signup paths, invitation accept (with email-match enforcement), pilot complimentary view, past-due simulation via Stripe test cards, subscription guard for locked accounts. +5. **Cutover**: flip `SELF_SERVE_ENABLED=true` and `VITE_SELF_SERVE_ENABLED=true` in prod. Pricing page goes live. +6. **Week 1 monitoring**: PostHog funnel; webhook logs; error rates. + +### Rollback strategy + +- Schema is forward-only — no down-migration for the backfills. +- Rollback = flag flip. `SELF_SERVE_ENABLED=false` reverts public surfaces; pilot users continue on `complimentary` status (benign — the existing schema supports it either way after step 8). +- New surfaces (pricing page, etc.) return 404 when the flag is off. +- Webhook handler stays live regardless. + +### Risks worth flagging + +| Risk | Mitigation | +|---|---| +| Pilot users confused by "Complimentary Pro" change | Pre-launch email + first-login toast | +| `is_paid` regression — paid metrics include comp accounts pre-fix | Audit `Subscription.is_paid` callers as part of step 1 of implementation; fix in same PR | +| Webhook misfires producing wrong subscription state | Idempotency table + alerting + Stripe webhook replay | +| Multi-head Alembic merge breaks in CI | Test `alembic upgrade heads` (plural) on a fresh DB before merging | +| Stripe Test vs. Live mode confusion | Distinct env vars per env; first prod transaction verified manually | +| OAuth callback `redirect_uri` drift across envs | Single `OAUTH_REDIRECT_BASE` env var; tested per env in validation | +| Email deliverability for verification + invitations + sales leads | Reuse existing `EmailService` pipeline; verify SPF/DKIM/DMARC alignment | +| Email-match enforcement at register breaks teammate accept if invitee mistypes their address | Clear error message; resend with corrected email is one click from the failure page | +| Subscription guard allowlist drift (a new endpoint added without thinking about lockout) | Add a CI test that exercises every router with a `canceled` subscription and verifies 402 unless explicitly allowlisted | +| Email-verification guard allowlist drift (a new endpoint added without thinking about unverified users past grace) | Same CI pattern — exercise every router with an unverified day-8 user and verify 403 unless explicitly allowlisted | +| Plan key rename (`free`/`pro`/`team` → `starter`/`pro`/`enterprise`) | Decision deferred to implementation plan; if rename, migration must update every reference in `subscriptions.plan` and `plan_limits.plan` | + +--- + +## Section 6 — Testing, rollout, open risks + +### Test strategy + +#### Backend (`pytest`) + +- **Unit tests** for `BillingService` methods. Stripe mocked via `respx`. Each method's happy path + at least one error path. +- **Webhook handler integration tests**: feed canned Stripe webhook payloads and assert resulting `subscriptions` state. One test per event type. **Idempotency test**: send the same event id twice, assert single state mutation. +- **`require_feature` integration tests**: parametrized over (plan, flag_key) pairs; test override resolution (`account_feature_overrides` beats `plan_feature_defaults`). +- **`require_active_subscription` integration tests**: + - Each `subscriptions.status` value × allowlisted/non-allowlisted route → expected 200 or 402. + - **Replaces and verifies the trial expiry change**: a `trialing` row with `current_period_end < now()` should NOT be mutated by the dep; the dep should return 402 on protected routes and 200 on allowlisted routes. + - "complimentary should not block protected routes" smoke test. +- **`require_verified_email_after_grace` integration tests**: + - Each combination of (verified, unverified-in-grace, unverified-past-grace) × (allowlisted, non-allowlisted route) → expected 200 or 403. + - OAuth-signup user has `email_verified_at` set at callback time → never blocked. + - User on day 6 unverified passes; user on day 8 unverified blocks; verifying mid-test transitions to passing. +- **Combined-guard test**: protected routers mounting both `require_active_subscription` and `require_verified_email_after_grace` reject an unverified expired-trial account with the appropriate error (whichever check fires first is acceptable; assert one of the two error payloads). +- **Subscription model property tests**: `is_active`, `is_paid`, `has_pro_entitlement` across every status × plan combination. +- **Auth integration tests**: + - `/auth/register` happy path + duplicate email + weak password + email-match enforcement when `account_invite_code` provided. + - `/auth/google/callback` and `/auth/microsoft/callback` with mocked OAuth provider responses. + - `/auth/email/send-verification` auto-fired by register. + - `/auth/email/verify` with valid / expired / already-used tokens (already covered; smoke regression). + - **OAuth-only user paths**: `/auth/login` rejects, `/auth/password/change` rejects, password reset suppressed. +- **Invitation tests**: + - `/accounts/me/invites` create now sends email (regression: today it doesn't). + - `/accounts/me/invites/bulk` creates N rows + sends N emails. + - Email-match enforcement at register. + - Expired/revoked token, idempotent re-accept. +- **Plan-limits + feature-flags admin tests**: existing tests stay; extend with a test that round-trips `plan_billing` fields through `/admin/plan-limits` PUT. +- **Anti-parrot guardrail**: existing `tests/test_prompt_anti_parrot.py` covers any new system prompts (verification email, invitation email, sales-lead intake) automatically. +- **Phase 4 RLS smoke test**: every new account-scoped endpoint exercised with a non-matching `app.current_account_id`. + +#### Frontend (Vitest + Playwright) + +- **Component tests** for `` (each subscription status branch + trialing-expired computed branch), ``, ``, ``, ``, ``, ``. +- **Hook tests** for `useFeature`, `useFeatureLimit`, `useTrialBanner`, `useBillingStore` (initial fetch, refetch on webhook event, refetch after Stripe Checkout success). +- **Playwright E2E**: + - Register → wizard step-by-step → dashboard. + - OAuth round-trip with mocked provider. + - Trial-end wall → plan picker → mock Stripe Checkout → activated state. + - Past-due banner via webhook simulation. + - Pilot complimentary view (no walls, no nudges, "Complimentary Pro" pill). + - Invitation accept (full flow with `account_invite_code` from a backend fixture; email-match success and failure paths). + +#### Manual validation phase (1-2 days before cutover) + +| Scenario | Method | +|---|---| +| Email signup → wizard → first session → trial-end synthetic time → Checkout → active | Real flow with Stripe test mode + a date-shimmed account | +| Google sign-in | Real Google account | +| Microsoft sign-in | Real Microsoft 365 account | +| Past-due simulation | Stripe test card `4000 0000 0000 0341` | +| Pilot complimentary banner + first-login toast | Log in as an existing pilot account post-deploy | +| Webhook signature mismatch handling | Send a forged webhook with bad signature, expect 400 + log entry | +| OAuth provider redirect_uri matches | Visual check on each environment's Google + Microsoft app config | +| `is_paid` audit | Query a known complimentary account: confirm `is_paid=False`, `has_pro_entitlement=True` | + +### Rollout monitoring + +#### PostHog event taxonomy + +- **Funnel**: `pricing_page_viewed`, `register_started`, `register_completed` (with `method`), `email_verification_sent`, `email_verification_completed`. +- **Wizard**: `welcome_wizard_step_completed` (step number), `welcome_wizard_skipped` (`from_step`), `welcome_wizard_completed`. +- **Activation**: `first_session_started` (existing), `psa_connected`, `teammate_invited`, `teammate_accepted_invite`. +- **Trial conversion**: `trial_modal_shown`, `trial_modal_dismissed`, `trial_ended_wall_shown`, `plan_picker_viewed`, `checkout_session_created`, `checkout_completed`, `checkout_abandoned`. +- **Feature-gate signal**: `feature_gate_blocked` (with `feature_key` + `current_plan`). +- **Sales**: `talk_to_sales_form_submitted` (with `source`), `complimentary_account_first_view`. + +#### Alerting + +- Stripe webhook signature failures > 1/hour. +- Stripe API errors during checkout-session creation > 1/hour. +- OAuth callback failures > 5/hour. +- Email send failures (`EmailService` errors) on verification or invitation paths. +- Any 500 from `/webhooks/stripe`. +- 402 rate spike on non-allowlisted endpoints (could indicate guard misconfiguration). + +#### Operational dashboards + +- Daily: trial signups, completed checkouts, MRR delta (using corrected `is_paid`). +- Weekly: trial→paid conversion rate, OAuth-method mix, wizard skip rate per step. +- Per-feature: `feature_gate_blocked` count by `flag_key`. + +### Stripe MCP tooling note + +Once the Stripe MCP plugin loads in a future Claude Code session, it speeds up two things: **debugging webhook state** for support cases and **ad-hoc subscription mutations** (compt'ing accounts, fixing stuck states). Worth using post-launch for ad-hoc support; not load-bearing for the spec. + +### Open risks and unknowns (carry-forward) + +| # | Item | Status | +|---|---|---| +| 1 | **Pricing numbers** ($/seat/month for Starter and Pro) | Out of design scope. Set during validation phase. Schema supports any value via `plan_billing.monthly_price_cents` / `annual_price_cents`. | +| 2 | **Stripe Tax** | Disabled in v1. Revisit when first international signup arrives. | +| 3 | **Multi-account membership** (one user in multiple shops) | Out of scope. v1 is one user → one account. | +| 4 | **Owner transfer** | **Existing capability** — `POST /accounts/me/transfer-ownership` (`accounts.py:150`). Surface in the redesigned Account → Team page. | +| 5 | **Annual billing UI** | Stripe Prices exist via `plan_billing.stripe_annual_price_id`, but the in-app picker only surfaces monthly in v1. Add later. | +| 6 | **SSO (SAML/OIDC) for Enterprise** | Promised on the pricing page Enterprise tier. Actual impl deferred until first paying Enterprise customer. Sales conversation must set expectations honestly. | +| 7 | **GDPR DPA template** | Trust strip claims "GDPR-ready DPA available." Founder/lawyer needs to produce the actual document — not eng work, but blocking the trust-strip claim being honest. | +| 7b | **SOC2 status** | Trust strip claims "SOC2 in progress." If the engagement isn't started by cutover, soften the trust-strip copy. | +| 8 | **Customer Portal cancellation customization** | Stripe-hosted Portal can't be customized. Acceptable for v1. | +| 9 | **Email deliverability** | First big surge may trip spam filters. Verify SPF/DKIM/DMARC alignment before cutover. | +| 10 | **Reverse-trial conversion math** | If trial→paid is bad post-launch, may need to flip to card-upfront. Schema supports it; policy decision based on data. Re-evaluate at week 4. | +| 11 | **Promo codes** | **Deferred from v1.** No `promo_codes` table. If a launch campaign needs them, add a separate table later with Stripe coupon semantics; do not retrofit `invite_codes`. | +| 12 | **Pricing page A/B testing** | Not in v1. PostHog has experiment tooling for A/B headlines later. | +| 13 | **OAuth-only password set-initial flow** | An OAuth-only user can't add a password later in v1. Out of scope; users who want a password can ask support to enable it manually. | +| 14 | **Plan key rename** | Existing `plan_limits` rows use `'free' / 'pro' / 'team'`. Public-facing tiers are Starter / Pro / Enterprise. Implementation plan decides whether to rename keys or maintain a display-name mapping in `plan_billing`. | + +--- + +## Appendix A — Endpoint inventory + +Categorized as **NEW**, **MODIFIED**, or **EXISTING (referenced)**. + +### Public + +| Status | Method | Path | Purpose | +|---|---|---|---| +| NEW (frontend route) | GET | `/pricing` | Public pricing page | +| NEW | POST | `/sales-leads` | Talk-to-sales form | +| NEW | GET/POST | `/auth/google/callback` | Google OAuth callback | +| NEW | GET/POST | `/auth/microsoft/callback` | Microsoft OAuth callback | +| EXISTING | POST | `/auth/email/send-verification` | (auto-called from register; today user-initiated) | +| EXISTING | POST | `/auth/email/verify` | Token consumption | +| MODIFIED | POST | `/auth/register` | Drops invite-code-required gate; calls `BillingService.start_trial()`; auto-sends verification email; **enforces email match against `account_invites.email` when `account_invite_code` is provided** | +| MODIFIED | POST | `/webhooks/stripe` | Stripe webhook handler. Stub exists at `app/api/endpoints/webhooks.py` (signature verification + early-out when `stripe_enabled=False`). This work fleshes out event handlers (`checkout.session.completed`, `customer.subscription.*`, `invoice.payment_*`), idempotency via `stripe_events`, and `BillingService.apply_subscription_event` integration. | + +### Authenticated user + +| Status | Method | Path | Purpose | +|---|---|---|---| +| EXISTING | GET | `/auth/me` | Stays user-focused — no billing data embedded | +| NEW | GET | `/billing/state` | Subscription + plan + plan_limits + resolved feature flags | +| NEW | POST | `/billing/checkout-session` | Create Stripe Checkout session | +| NEW | GET | `/billing/portal-session` | Create Stripe Customer Portal session | +| NEW | GET | `/usage/{flag_or_limit_key}` | Live usage count for quantitative limits | +| NEW | PATCH | `/users/me/onboarding-step` | Persist welcome wizard step state (writes `accounts.name`, `accounts.team_size_bucket`, `accounts.primary_psa`, `users.role_at_signup`) | +| EXISTING | POST | `/accounts/me/transfer-ownership` | Owner transfer (no change) | +| MODIFIED | POST | `/accounts/me/invites` | **Now sends invite email at create-time** (today only resend sends) | +| NEW | POST | `/accounts/me/invites/bulk` | Wraps single-create in a loop; sends email per row | +| EXISTING | POST | `/accounts/me/invites/{id}/resend` | (no change) | +| NEW | DELETE | `/accounts/me/invites/{id}` | Soft-revoke an invite by setting `revoked_at`. (No DELETE/revoke route exists today; only POST create, POST resend, GET list.) | + +### Super-admin (existing — referenced) + +| Status | Method | Path | Purpose | +|---|---|---|---| +| MODIFIED | GET | `/admin/plan-limits` | Response now includes `plan_billing` fields per row | +| MODIFIED | PUT | `/admin/plan-limits` | Accepts `plan_billing` fields in payload (single transaction) | +| EXISTING | GET/POST/PUT/DELETE | `/admin/plan-limits/account-overrides` | (no change) | +| EXISTING | GET/POST/PUT/DELETE | `/admin/feature-flags` | (no change) | +| EXISTING | PUT | `/admin/feature-flags/plan-defaults` | (no change) | +| EXISTING | GET/POST/DELETE | `/admin/feature-flags/account-overrides` | (no change) | + +No new combined `/admin/plans` admin page in v1. + +--- + +## Appendix B — Glossary + +- **Reverse trial**: time-bounded full-access trial with no card required at signup; card requested before billing kicks in. +- **Sales-assist (E)**: dedicated path for Enterprise prospects via "Talk to sales" CTA → contact form → manual onboarding by founder/sales. +- **Wedge**: Escalation Mode — the magic-moment feature pilots are evaluated against (≥1.0 hour saved per week per pilot per kill-switch criteria). +- **Complimentary**: permanent, non-time-bounded `subscriptions.status='complimentary'` value for grandfathered pilot users. No nags, no walls, full Pro entitlement. Distinct from `trialing` in that it never expires; distinct from `active` in that it doesn't count toward paid/MRR metrics. +- **Has Pro entitlement**: a property derived from `(status, plan, current_period_end)` that answers "can this account access Pro features right now?" — true for paid Pro, complimentary Pro, and active trials. Used by `require_feature` and `require_active_subscription`. +- **Locked subscription**: computed state `(status='trialing' AND current_period_end < now())` OR `(status IN ('canceled', 'incomplete'))`. No mutation occurs; `require_active_subscription` raises 402 on protected routes. +- **Plan keys**: `plan_limits.plan` is the canonical key; `plan_billing` joins on it; `subscriptions.plan` is the per-account key. Public-facing tier names (Starter / Pro / Enterprise) are display labels via `plan_billing.display_name`.