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>
63 KiB
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
- Front-of-funnel — public
/pricingpage (B-style: comparison table + testimonial slot + trust strip), sales-lead capture form, reworked/registerform with OAuth options. - 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).
- Billing integration over existing schema — extend
plan_limitswith a siblingplan_billingtable (Stripe IDs + public catalog metadata); seed Starter / Pro / Enterprise rows inplan_limits; seedfeature_flags+plan_feature_defaultsfor the Pro/Starter gating split; addsubscriptions.status='complimentary'value; replacedeps.py:109trial-expiry mutation with computed checks; add aBillingService, Stripe webhook handler, andrequire_active_subscriptiondep. Reuses existing/admin/plan-limitsand/admin/feature-flagsadmin surfaces. - Auth additions — Google + Microsoft OAuth via a new
oauth_identitiestable (users.password_hashbecomes nullable). Extend existingemail_verification_tokensflow with auto-send on register and a 7-day soft-grace dashboard wall. Extend existingaccount_invitesto 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/Usermodel and theis_super_admin/account_role/is_team_adminpermission hierarchy (withaccount_roleenum'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-flagsadmin endpoints (extended, not replaced)/accounts/me/transfer-ownership(existing — covers owner transfer, no longer flagged "out of scope")/accounts/me/invitesand/me/invites/{id}/resend(extended with email send + email-match enforcement)
What's deprecated
- Invite-code-as-registration-gate. The
invite_codestable is preserved (historical foreign keys fromusers.invite_code_id); the gate is removed at/auth/register. beta_signup.pywaitlist 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_assistantkey. - 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 isString(50)so no schema migration is required for the value itself; we update the value-level checks only. Subscription.is_activealready returnsTruefor('active', 'trialing')— extend to include'complimentary'.Subscription.is_paid(currentlyplan in ('pro', 'team')) → narrow toplan in ('pro', 'team') AND status NOT IN ('complimentary',). Used for revenue / paid-customer / MRR calculations only.- New
Subscription.has_pro_entitlementproperty: 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_atalready exists. No add. Email-verification flow uses it.password_hash— changenullable=False→nullable=Trueto 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 writesusers.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 asonboarding_step_completed = step_number - 1). - Wizard is "done" when
onboarding_dismissed = TRUEORonboarding_step_completed >= 3. /welcomeredirect 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 onlyused_at; revoke (resend handler ataccounts.py:323) currently deletes the row. Addrevoked_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).
add_oauth_identities.py— new table.users_password_hash_nullable.py— alter to nullable.users_add_role_at_signup_and_onboarding_step.py— addrole_at_signupandonboarding_step_completedcolumns.accounts_add_wizard_columns.py— addteam_size_bucket,primary_psa. (accounts.namealready exists; wizard writes to it.)account_invites_add_revoked_at_and_email_sent_at.py— add columns.add_plan_billing.py— new sibling table. Seeds Starter / Pro / Enterprise rows withstripe_product_id/stripe_*_price_idleft NULL. Existingplan_limitsrows 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-limitsPUT (extended to accept Stripe fields) or a one-offpython -m scripts.sync_stripe_plan_idsadmin command driven by env vars. Migrations stay environment-agnostic — they don't read live mode vs. test mode IDs.seed_pro_starter_feature_flags.py— register feature keys (psa_integration,escalation_mode,script_builder,analytics_dashboards,knowledge_flywheel,team_admin_full,monthly_sessionsquantitative,seatsquantitative,sso,audit_log) infeature_flags; populateplan_feature_defaultsper the Pro/Starter split.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.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_identitiesis account-adjacent (joined viauser_id), but RLS onusersis admin-DB-only (perdeps.pyget_current_userusesget_admin_db). Treatoauth_identitiesthe same — no per-tenant RLS policy; queries use admin session. Verify against currentuserstable policy before merging.plan_billingis global (joinsplan_limits.plan, also global). No RLS.sales_leads,stripe_eventsare global. No RLS.account_invitesalready has its policy (account-scoped). No change.subscriptionsalready 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.tsxgets 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_identitiesrow + Account + Subscription on Pro trial viaBillingService.start_trial), marksemail_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 (withpassword_hashset) + Account, callsBillingService.start_trial, sends verification email via existingEmailService.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 toaccounts.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 toaccount_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
AppLayoutrenders based onsubscriptions.statusandcurrent_period_end:trialingANDcurrent_period_end > now(): "Pro trial · Nd" — blue, amber when ≤3d remaining, red when ≤1d.trialingANDcurrent_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):
- Verify your email — auto-completes on link click; hidden if signed up via OAuth.
- Set up your shop — completes when
users.onboarding_step_completed >= 1. - Run your first FlowPilot session — the wedge. Highlighted as the headline action when prior items are complete.
- Connect your PSA — auto-completes when first PSA connection saved. Pre-fills the provider based on welcome wizard selection.
- Invite a teammate — auto-completes when first invitation is sent.
- 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 setsusers.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_gracedep (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
useTrialBannerhook reading fromuseBillingStore(which pollsGET /billing/state); per-user dismiss recorded in localStorage. Email day 10 + day 13 (EmailService.send_trial_ending). - Day 14: when
subscriptions.status='trialing'ANDcurrent_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 aftercurrent_period_end— computed at render time ascurrent_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_emailfrom Userline_items(price_id fromplan_billing× quantity = seats)mode='subscription'subscription_data.trial_end = current_period_endif 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_userrow fromget_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-Signatureheader →stripe.Webhook.construct_event(payload, sig, STRIPE_WEBHOOK_SECRET)→ 400 on mismatch. - Idempotency: every event recorded in
stripe_eventskeyed by Stripe's event id. If the row exists, return 200 immediately. - Uses
_admin_session_factory()— nocurrent_account_idis 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_cachekeyed byaccount_id. TTL 5 minutes. - Explicit invalidation triggers:
- Stripe webhook handler when
subscriptionsstate changes (account-keyed invalidation). /admin/plan-limitsPUT (invalidate all accounts on that plan, since plan-wide limits / billing fields changed)./admin/plan-limits/account-overridesPOST/PUT/DELETE (account-keyed)./admin/feature-flagsPUT/DELETE on flag definitions (full-cache flush)./admin/feature-flags/plan-defaultsPUT (invalidate all accounts on that plan)./admin/feature-flags/account-overridesPOST/DELETE (account-keyed).
- Stripe webhook handler when
- 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— readsenabled_features[key]fromuseBillingStore.useFeatureLimit(key): { used, limit, percentage, isAtLimit }— combinesplan_limits[key]with a lazy/usage/{key}count.useTrialBanner(): { stage: 'pristine' | 'warning' | 'urgent' | 'expired', daysRemaining }— derived fromsubscription.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 surfaceplan_billingfields in the editor (single PUT round-trips both tables in one transaction)./admin/feature-flags— unchanged. Toggling a flag'splan_feature_defaultsenables/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:
- 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/stripewith the events listed in Section 4. Save the signing secret.
- Create Products:
- Railway env vars (per environment):
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PUBLISHABLE_KEY(frontend; needsARG+ENVinfrontend/Dockerfileper 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_announcementto 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_codestable preserved.User.invite_code_idforeign keys preserved for historical pilots.- Registration handler (
/auth/register) drops the invite_code-required gate. TheUserCreate.invite_codefield stays in the schema for backward compatibility but is ignored at registration. No new validations against theinvite_codestable at signup. - No promo-code repurposing. Invite codes simply stop being consumed.
Beta-signup deprecation
beta_signup.pyendpoint 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=betalink. Preserve the table; do not drop.
Deploy ordering — dark launch then cutover
- Backend deploy with
SELF_SERVE_ENABLED=false: all new endpoints exist (webhook handler, billing, OAuth callbacks, sales-leads, bulk invite, billing/state)./auth/registerretains the existing invite-code requirement./pricingreturns 404. Webhook handler is live. - Frontend deploy with
VITE_SELF_SERVE_ENABLED=false: new surfaces are routed but hidden behind the flag. - Stripe live-mode configuration in prod (manual, ~30 min).
- 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.
- Cutover: flip
SELF_SERVE_ENABLED=trueandVITE_SELF_SERVE_ENABLED=truein prod. Pricing page goes live. - 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=falsereverts public surfaces; pilot users continue oncomplimentarystatus (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
BillingServicemethods. Stripe mocked viarespx. Each method's happy path + at least one error path. - Webhook handler integration tests: feed canned Stripe webhook payloads and assert resulting
subscriptionsstate. One test per event type. Idempotency test: send the same event id twice, assert single state mutation. require_featureintegration tests: parametrized over (plan, flag_key) pairs; test override resolution (account_feature_overridesbeatsplan_feature_defaults).require_active_subscriptionintegration tests:- Each
subscriptions.statusvalue × allowlisted/non-allowlisted route → expected 200 or 402. - Replaces and verifies the trial expiry change: a
trialingrow withcurrent_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.
- Each
require_verified_email_after_graceintegration 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_atset 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_subscriptionandrequire_verified_email_after_gracereject 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_entitlementacross every status × plan combination. - Auth integration tests:
/auth/registerhappy path + duplicate email + weak password + email-match enforcement whenaccount_invite_codeprovided./auth/google/callbackand/auth/microsoft/callbackwith mocked OAuth provider responses./auth/email/send-verificationauto-fired by register./auth/email/verifywith valid / expired / already-used tokens (already covered; smoke regression).- OAuth-only user paths:
/auth/loginrejects,/auth/password/changerejects, password reset suppressed.
- Invitation tests:
/accounts/me/invitescreate now sends email (regression: today it doesn't)./accounts/me/invites/bulkcreates 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_billingfields through/admin/plan-limitsPUT. - Anti-parrot guardrail: existing
tests/test_prompt_anti_parrot.pycovers 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_codefrom 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(withmethod),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(withfeature_key+current_plan). - Sales:
talk_to_sales_form_submitted(withsource),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 (
EmailServiceerrors) 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_blockedcount byflag_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 fromtrialingin that it never expires; distinct fromactivein 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 byrequire_featureandrequire_active_subscription. - Locked subscription: computed state
(status='trialing' AND current_period_end < now())OR(status IN ('canceled', 'incomplete')). No mutation occurs;require_active_subscriptionraises 402 on protected routes. - Plan keys:
plan_limits.planis the canonical key;plan_billingjoins on it;subscriptions.planis the per-account key. Public-facing tier names (Starter / Pro / Enterprise) are display labels viaplan_billing.display_name.