Files
resolutionflow/docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md
Michael Chihlas 278b9342b4 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>
2026-05-06 19:14:29 -04:00

63 KiB
Raw Blame History

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_hashchange nullable=Falsenullable=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.pyUPDATE 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.

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:

# 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

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

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

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:

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/teamstarter/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 capabilityPOST /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.