docs(spec): self-serve signup & onboarding design
Adds docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md. Six-section design for opening ResolutionFlow to public self-serve registration with a 14-day reverse trial on Pro, Stripe-backed billing, sales-assist Enterprise lane, and a hybrid welcome wizard + dashboard onboarding. Reuses existing infrastructure (subscriptions, plan_limits, feature_flags, plan_feature_defaults, account_feature_overrides, account_invites, email_verification_tokens, /admin/plan-limits, /admin/feature-flags, /accounts/me/transfer-ownership, /webhooks/stripe stub). New schema is intentionally small: oauth_identities, plan_billing (sibling to plan_limits), sales_leads, stripe_events, plus column additions for OAuth identity model nullability, wizard step state, and pilot-account complimentary status. Replaces deps.py:109 trial auto-downgrade with a non-mutating computed expiry check enforced by a new require_active_subscription dep. Adds a sibling require_verified_email_after_grace dep to enforce the 7-day email verification grace at the API layer (frontend wall is UX over the same rule). Defers promo codes from v1. No new combined /admin/plans surface — existing admin endpoints handle plan/feature configuration with extended response shape. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<EmailVerificationWall />` 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 `<EmailVerificationWall />` 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 (`<EmailVerificationGate />`) wraps the dashboard route. Day 1-6 unverified shows the soft banner; day 7+ unverified renders `<EmailVerificationWall />`.
|
||||
- **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 `<TrialEndedWall />` 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 `<EmailVerificationWall />` 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<string, boolean>, -- 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`.
|
||||
- `<FeatureGate feature="psa_integration" fallback={<UpgradePrompt />}>...children</FeatureGate>` — 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 `<TrialPill />` (each subscription status branch + trialing-expired computed branch), `<NextStepCard />`, `<EmailVerificationBanner />`, `<EmailVerificationWall />`, `<TrialEndedWall />`, `<FeatureGate />`, `<UpgradePrompt />`.
|
||||
- **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`.
|
||||
Reference in New Issue
Block a user