# 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`.