Adds two phase plans alongside the spec at docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md: - Phase 1 (backend foundation, 26 tasks across 8 sub-phases A-H): schema migrations, subscription model + new guards, BillingService, Stripe webhook handler extension, OAuth callbacks, email verification auto-send + email-match enforcement, account-invite extensions, GET /billing/state, pilot user backfill. Step-by-step granularity with full code blocks per writing-plans skill. - Phase 2 (frontend + cutover, 21 tasks across 7 sub-phases I-O): Phase-1-deferred endpoints, useBillingStore + hooks + gating components, register redesign + OAuth buttons + accept-invite, welcome wizard, dashboard redesign, pricing page + contact-sales, beta-signup deprecation, cutover. Higher-altitude — defines contracts, acceptance criteria, integration tests; leaves component-detail decisions to implementer. Each phase ends in a mergeable PR. Cutover is gated behind SELF_SERVE_ENABLED + VITE_SELF_SERVE_ENABLED. Execution deferred to a future session. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
45 KiB
Self-Serve Signup & Onboarding — Phase 2: Frontend + Cutover
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
Granularity note: Unlike Phase 1, this plan defines contracts and acceptance criteria — not every component detail. Implementers exercise judgment on internal structure (hooks vs. props, file splits, CSS organization) as long as the contracts hold and integration tests pass. Steps use checkbox (
- [ ]) syntax for tracking; each task is one mergeable PR.
Goal: Layer the user-facing self-serve flow on top of the Phase 1 backend foundation — pricing page, OAuth buttons + register redesign, welcome wizard, dashboard redesign with trial pill + next-step card + checklist, accept-invite page, sales contact form, billing portal — gated behind SELF_SERVE_ENABLED and VITE_SELF_SERVE_ENABLED until cutover.
Architecture: Frontend reads billing state from a new useBillingStore Zustand store fed by GET /billing/state. New routes layer on the existing React Router v7 + lazyWithRetry pattern. Wizard state is server-persisted via PATCH /users/me/onboarding-step. Authenticated routes mount under existing AppLayout; public routes (pricing, contact-sales, accept-invite, verify-email) are top-level. Cutover is two flag flips: backend SELF_SERVE_ENABLED=true, frontend VITE_SELF_SERVE_ENABLED=true.
Tech Stack: React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config), Zustand (immer + zundo), React Router v7, Axios, Lucide. Backend additions: a few small endpoints (Phase 1 left them out) — see Phase I.
Spec reference: docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md (commit bbb01ef).
Phase 1 reference: docs/superpowers/plans/2026-05-06-self-serve-signup-phase-1-backend.md.
Phase Sequencing
Each phase ends in a mergeable PR. Frontend gates everything behind VITE_SELF_SERVE_ENABLED so the new surfaces stay invisible to public users until Phase O cutover.
| Phase | Tasks | Outcome |
|---|---|---|
| I | 27–31 | Backend endpoints Phase 1 deferred + SELF_SERVE_ENABLED flag + /admin/plan-limits extension |
| J | 32–34 | Frontend billing foundation: useBillingStore, hooks, gating components — proven against Phase 1 backend |
| K | 35–37 | Auth surfaces: register redesign with OAuth buttons, accept-invite page, email-verification surfaces |
| L | 38–39 | Welcome wizard — 3 steps with persistence |
| M | 40–41 | Dashboard redesign — trial pill, next-step card, checklist redesign |
| N | 42–44 | Public surfaces: pricing page, contact-sales form, landing-page CTA, beta-signup 307 |
| O | 45–47 | Cutover: Stripe live-mode setup, internal validation, feature-flag flip |
Phase I — Backend endpoints + admin extension + feature flag
Task 27: BillingService.open_customer_portal + GET /billing/portal-session
Outcome: Authed users can request a Stripe-hosted Customer Portal URL for card updates and cancellation.
Contract:
GET /api/v1/billing/portal-session
→ 200 { url: string }
→ 503 when STRIPE_SECRET_KEY unset
→ 400 when account has no stripe_customer_id (must complete checkout first)
BillingService.open_customer_portal(account) creates a stripe.billing_portal.Session with return_url=$FRONTEND_URL/account/billing and returns the session URL.
Acceptance criteria:
- Endpoint mounted at
/billing/portal-sessionand is in the_SUBSCRIPTION_GUARD_ALLOWLISTand_EMAIL_VERIFICATION_ALLOWLIST(so it works for canceled / unverified-past-grace users who need to update billing). - Returns 400 with
{"error": "no_stripe_customer"}whenaccount.stripe_customer_id is None. - Stripe call mocked via
respx; happy-path test asserts shape{url: ...}.
Integration test added:
test_billing_portal_returns_url_for_account_with_stripe_customer
Commit: feat(billing): add BillingService.open_customer_portal + GET endpoint
Task 28: PATCH /users/me/onboarding-step
Outcome: Welcome wizard can persist Step 1/2/3 state to the server.
Contract:
PATCH /api/v1/users/me/onboarding-step
body: {
step: 1 | 2 | 3,
action: "complete" | "skip",
data?: {
// step 1
company_name?: string,
team_size_bucket?: "1-2"|"3-5"|"6-10"|"11-25"|"26+",
role_at_signup?: "owner"|"lead_tech"|"tech"|"other",
// step 2
primary_psa?: "connectwise"|"autotask"|"halopsa"|"none",
// step 3 has no data — invitations posted separately to /accounts/me/invites/bulk
},
}
→ 200 { onboarding_step_completed: int, onboarding_dismissed: false }
Writes:
- step=1 + action=complete →
accounts.name,accounts.team_size_bucket,users.role_at_signup,users.onboarding_step_completed=1 - step=1 + action=skip →
users.onboarding_step_completed=1only (no field writes) - step=2 →
accounts.primary_psa(only on complete) +users.onboarding_step_completed=2 - step=3 →
users.onboarding_step_completed=3(the actual invites POST is separate)
Validates: step cannot decrease; action="skip" ignores the data payload.
Endpoint also exposes a sibling: POST /users/me/onboarding-dismiss-rest → sets users.onboarding_dismissed=TRUE. Used by "Skip the rest" button.
Acceptance criteria:
- In
_EMAIL_VERIFICATION_ALLOWLIST(so users can move through the wizard before verifying email). - In
_SUBSCRIPTION_GUARD_ALLOWLIST(wizard runs during the trial; never gated). - Refusing to decrease
stepis enforced (a step=2 PATCH followed by step=1 returns 400). - Tests cover: complete with data writes fields; skip without data only advances step; idempotent re-PATCH of same step.
Integration tests added:
test_onboarding_step1_complete_writes_account_name_and_team_size_and_roletest_onboarding_step2_skip_advances_without_psatest_onboarding_step_cannot_decreasetest_onboarding_dismiss_rest_sets_flag
Commit: feat(onboarding): add PATCH /users/me/onboarding-step + dismiss-rest
Task 29: POST /sales-leads endpoint
Outcome: Public Talk-to-sales form has somewhere to post.
Contract:
POST /api/v1/sales-leads
body: {
email: string,
name: string,
company: string,
team_size?: string,
message?: string,
source: "pricing_page" | "register_footer" | "landing_page",
posthog_distinct_id?: string,
}
→ 201 { id: uuid, status: "received" }
Public — no auth required. Rate-limit: max 5 submissions per IP per hour (use existing core.rate_limit).
Side effects:
- Insert
sales_leadsrow. - Fire-and-forget
EmailService.send_sales_lead_notificationtosettings.SALES_LEAD_RECIPIENT_EMAIL(new env var, defaultsales@resolutionflow.com). - Emit PostHog server-side event
talk_to_sales_form_submittedwithsourceproperty.
Acceptance criteria:
- Anti-spam: rate-limited per IP.
- Email send failure doesn't fail the request (logged warning).
- Sales-lead recipient email is configurable; defaults to a placeholder until cutover.
Integration tests:
test_sales_lead_creates_row_and_sends_notification_emailtest_sales_lead_rate_limited_after_5_per_hour
Commit: feat(sales): add POST /sales-leads public endpoint
Task 30: Extend /admin/plan-limits to surface plan_billing fields
Outcome: Super-admins can manage plan_billing (Stripe IDs, display names, prices, public/archived flags) via the same admin page they already use.
Contract change:
GET /api/v1/admin/plan-limits → list[PlanLimitWithBillingResponse]
PlanLimitWithBillingResponse extends PlanLimitResponse with:
display_name?: string
description?: string
monthly_price_cents?: int | null
annual_price_cents?: int | null
stripe_product_id?: string | null
stripe_monthly_price_id?: string | null
stripe_annual_price_id?: string | null
is_public?: bool
is_archived?: bool
sort_order?: int
PUT /api/v1/admin/plan-limits accepts the same fields; updates plan_billing
in the same transaction. If a plan_billing row doesn't exist for the plan,
PUT creates it.
Acceptance criteria:
- Single PUT round-trips both
plan_limitsandplan_billingin one transaction. - Cache invalidation:
app.state.billing_cacheflushed for all accounts on the affected plan. - No new admin page in v1 — existing
/admin/plan-limitsUI just gets new form fields.
Integration tests:
test_admin_plan_limits_get_includes_plan_billing_fields_when_presenttest_admin_plan_limits_put_creates_plan_billing_rowtest_admin_plan_limits_put_invalidates_billing_cache
Commit: feat(admin): extend /admin/plan-limits to manage plan_billing fields
Task 31: Wire SELF_SERVE_ENABLED feature flag
Outcome: A single flag controls whether the new public-facing self-serve flow is exposed.
Contract:
Backend:
settings.SELF_SERVE_ENABLED: bool = False(already added in Phase 1 Task 14).- New endpoint
GET /api/v1/config/public(no auth) returns{self_serve_enabled: bool, oauth_providers: ["google", "microsoft"] | []}— frontend reads this once at load.
Frontend:
VITE_SELF_SERVE_ENABLEDenv var (build-time bake-in per Lesson 60).- New
useAppConfighook: prefers backend/config/publicresponse, falls back toVITE_SELF_SERVE_ENABLEDfor build-time gating. - Public routes (
/pricing,/contact-sales,/accept-invite, OAuth callbacks) return 404 from the frontend router whenself_serve_enabled === false. - Register page hides OAuth buttons + invite-code-removed copy when flag is off (preserves the existing invite-code-required register flow).
Acceptance criteria:
- Flag is OFF by default in all envs except where explicitly enabled.
- When OFF: existing
/auth/registerinvite-code flow still works exactly as today. - When ON: new flows are reachable; invite-code requirement is removed (the field still exists in the schema for backward-compat but the gate-check accepts NULL).
Integration tests:
test_get_config_public_returns_self_serve_flagtest_register_invite_code_required_when_self_serve_disabled(regression)test_register_invite_code_optional_when_self_serve_enabled
Commit: feat(config): add SELF_SERVE_ENABLED flag + GET /config/public
Phase J — Frontend billing foundation
Task 32: useBillingStore Zustand store + GET /billing/state integration
Outcome: Frontend has a single source of truth for subscription / plan / feature state.
Contract — store shape:
// frontend/src/store/billingStore.ts
interface BillingState {
subscription: {
status: 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'complimentary'
plan: string
current_period_start: string | null // ISO
current_period_end: string | null // ISO
cancel_at_period_end: boolean
seat_limit: number | null
has_pro_entitlement: boolean
is_paid: boolean
} | null
planBilling: {
display_name: string
description: string | null
monthly_price_cents: number | null
annual_price_cents: number | null
} | null
planLimits: Record<string, unknown>
enabledFeatures: Record<string, boolean>
isLoading: boolean
error: string | null
}
interface BillingStore extends BillingState {
fetch: () => Promise<void>
refetch: () => Promise<void>
reset: () => void
}
Behavior:
- Auto-fetches on auth-store login (subscribe to
authStore). - Auto-resets on logout.
- Polls every 60s while the dashboard is mounted (simple
useIntervalin a top-level component is fine — no SSE for v1). refetch()is exposed for explicit refresh after Stripe Checkout success-redirect.
Acceptance criteria:
- Initial state is null/empty; populates after first successful fetch.
- 401 from
/billing/statetriggers logout via existing axios interceptor (no special handling needed). - Polling disabled when no user is logged in.
Integration tests (Vitest):
useBillingStore fetches on login and populates subscriptionuseBillingStore resets on logoutuseBillingStore refetch overwrites stale data
Commit: feat(billing): add useBillingStore and /billing/state integration
Task 33: useFeature, useFeatureLimit, useTrialBanner hooks
Outcome: Components can ask "is this feature on?" / "how many sessions left?" / "what stage is the trial in?" without re-implementing the read.
Contract — hook signatures:
// useFeature: enabled boolean for a feature key
function useFeature(flagKey: string): boolean
// useFeatureLimit: progress against a quantitative limit
function useFeatureLimit(field: keyof PlanLimits): {
used: number // from /api/v1/usage/{field} (lazy fetch, cached 60s)
limit: number | null
percentage: number | null // null when limit is null (unlimited)
isAtLimit: boolean
isLoading: boolean
}
// useTrialBanner: derives stage from subscription state
function useTrialBanner(): {
stage: 'pristine' | 'warning' | 'urgent' | 'expired' | 'complimentary' | 'paid' | 'past_due' | 'canceled' | null
daysRemaining: number | null
}
Stage derivation:
subscription.status === 'complimentary'→complimentarysubscription.status === 'active'→paidsubscription.status === 'past_due'→past_duesubscription.status === 'canceled'→canceledsubscription.status === 'trialing'ANDcurrent_period_end > now()→pristine(>3 days),warning(1–3),urgent(<1)subscription.status === 'trialing'ANDcurrent_period_end <= now()→expired
Acceptance criteria:
useFeatureLimitdoes NOT block render — returnsisLoading=trueuntil usage data arrives.useTrialBannerreturnsnullwhen subscription is null (no flicker on initial load).- All three hooks subscribe to
useBillingStoresuch that updates propagate without manual refetch.
Integration tests (Vitest):
useFeature returns false when flag absentuseFeatureLimit transitions isLoading → loadeduseTrialBanner stage matches subscription state matrix
Commit: feat(billing): add useFeature, useFeatureLimit, useTrialBanner hooks
Task 34: FeatureGate, UpgradePrompt, EmailVerificationGate components
Outcome: Three drop-in components that handle the most common gating patterns. Component implementation details (props, layout, Tailwind classes) are at implementer's discretion as long as the API holds.
Contracts:
// FeatureGate: render children if feature enabled, else fallback (default <UpgradePrompt />)
<FeatureGate feature="psa_integration" fallback={<UpgradePrompt feature="psa_integration" />}>
<PsaConfigPanel />
</FeatureGate>
// UpgradePrompt: standardized "this feature is on Pro" affordance with CTA
<UpgradePrompt feature="psa_integration" /> // resolves display name + plan name internally
// EmailVerificationGate: wraps protected content; renders <EmailVerificationWall /> past grace
<EmailVerificationGate>
<DashboardContent />
</EmailVerificationGate>
Behavior:
<FeatureGate>reads fromuseFeature(feature). Server-side check viarequire_featureis the security boundary; this is UX.<UpgradePrompt>CTA links to/account/billing/select-plan.<EmailVerificationGate>readsusers.email_verified_at+users.created_atfromauthStore.user. Day 1–6 unverified renders children (banner shown elsewhere). Day 7+ unverified renders<EmailVerificationWall>.
Acceptance criteria:
- All three components are exported from
frontend/src/components/common/. - No CSS-in-JS — Tailwind classes per existing pattern.
- Lock icon + greyed style for
<UpgradePrompt>matches the design system tokens (nobg-accentfor non-interactive elements per design lessons).
Integration tests (Vitest + Playwright):
FeatureGate renders children when flag enabled, fallback when disabled(Vitest)UpgradePrompt CTA navigates to /account/billing/select-plan(Vitest)EmailVerificationGate renders wall on day 8 unverified user(Vitest, mocked authStore)
Commit: feat(billing): add FeatureGate, UpgradePrompt, EmailVerificationGate components
Phase K — Auth surfaces
Task 35: Register page redesign with OAuth buttons + invite-code-optional
Outcome: New register flow supports email+password OR Google OR Microsoft, with promo code field collapsed (deferred per spec) and the legacy invite-code field invisible when SELF_SERVE_ENABLED.
Contract:
Frontend route stays /register. Component lives at frontend/src/pages/RegisterPage.tsx (modified, not replaced).
Top-of-page CTAs:
- "Continue with Google" button → opens OAuth window → on callback, POSTs
codetoPOST /api/v1/auth/google/callback→ stores tokens via existing auth-store login flow → redirects to/welcome(new user) or/(returning). - "Continue with Microsoft" button → same shape against
/auth/microsoft/callback. - "or sign up with email" divider, then existing email + password form.
Removed/conditional:
- Invite-code field — hidden when
useAppConfig().self_serve_enabled === true. When the flag is off, the existing required-invite-code flow runs unchanged. - Promo-code field — not in v1 (deferred per spec). UI should NOT include it.
/register?plan=pro query param is captured into localStorage (rf-intended-plan) so BillingService.start_trial (already runs on Pro by default) can later be enriched OR the in-app picker can preselect.
Acceptance criteria:
- Email+password register call still works; auto-sends verification email per Phase 1 Task 20.
- OAuth callback creates User + Account + Subscription per Phase 1 Task 17/18; lands on
/welcome. - When self-serve disabled: invite-code flow visible, OAuth buttons hidden.
- When self-serve enabled: invite-code field hidden, OAuth buttons visible.
- Existing test users (
engineer@resolutionflow.example.cometc.) can still log in via/loginunchanged.
Integration tests (Playwright):
register email+password → verification email queued → land on /welcomeregister via Google OAuth (mocked provider) → land on /welcomeregister page hides OAuth + shows invite-code field when self_serve_enabled is false
Commit: feat(auth): redesign /register with OAuth buttons; hide invite-code under flag
Task 36: AcceptInvitePage at /accept-invite?code=...
Outcome: Invitee from email can join an existing account with set-password OR Google OR Microsoft.
Contract:
New top-level route /accept-invite?code=<32-char-code>. Component at frontend/src/pages/AcceptInvitePage.tsx.
Flow:
- On mount,
GET /api/v1/accounts/invites/{code}/lookup(NEW endpoint — see acceptance criteria) returns{account_name, inviter_name, invited_email, role}or 404/410 (expired/revoked/used). - Render: "Join {account_name} on ResolutionFlow" + email locked to
invited_email+ three sign-in options (set password, Google, Microsoft). - On submit, POST to existing
/auth/registerwithaccount_invite_codeand the email matchinginvited_email(per Phase 1 Task 20 enforcement). - OAuth path: launch provider with state including the invite code; callback POSTs
{code, account_invite_code, invited_email}to handle linking. - Success → land on
/?welcome=teammate(suppresses welcome wizard for invitees per spec).
Backend addition needed (small):
GET /api/v1/accounts/invites/{code}/lookup
→ 200 {account_name, inviter_name, invited_email, role}
→ 404 invite_invalid_or_expired_or_revoked
This is a public endpoint (no auth) reading account-scoped data, so uses _admin_session_factory() per the Phase 4 RLS pattern.
Acceptance criteria:
- Invalid/expired/revoked codes show a clear "ask {inviter} to resend" message with a link to email the inviter (via
mailto:). - Email field is locked to
invited_email— frontend doesn't even render an editable input. - OAuth path requires the provider's email to match
invited_email; mismatch returns the sameinvite_email_mismatcherror from Phase 1. - Successful accept lands on
/?welcome=teammate; the dashboard shows a "Welcome to {account_name}" toast and a checklist with "Setup shop" + "Invite a teammate" auto-marked done.
Integration tests (Playwright):
accept invite with email/password → join existing account → land on /?welcome=teammateaccept invite with Google OAuth (matching email) → land on dashboardaccept invite with mismatched email → see invite_email_mismatch erroraccept invite with expired code → see resend message
Commit: feat(auth): add /accept-invite page + lookup endpoint
Task 37: Email verification surfaces — banner, wall, /verify-email route
Outcome: UI for the soft 7-day grace + day-7 wall.
Contract:
<EmailVerificationBanner />— thin top-of-dashboard bar visible whenusers.email_verified_at IS NULLAND grace not expired. "Resend" link calls existingPOST /auth/email/send-verification.<EmailVerificationWall />— full-content replacement when grace expired. Same "Resend" CTA + a "Sign out" button./verify-email?token=...— frontend route that calls existingPOST /auth/email/verifyand shows success/error state. On success, refreshes the auth store and redirects to/?verified=1toast.
Acceptance criteria:
- Banner contrasts well in dark theme (use
bg-warning-dimper design tokens, not custom colors). - Wall has a "Sign out" button so a user with a typo'd email can recover.
- Verification success toast does not double-fire on remount.
- If user is already verified when hitting
/verify-email, the page shows "Already verified" rather than failing.
Integration tests (Playwright):
unverified day-1 user sees banner on dashboardunverified day-8 user sees wall, can sign out, can resendclicking verification link verifies and redirects to dashboard with toast
Commit: feat(auth): add email verification banner, wall, /verify-email page
Phase L — Welcome wizard
Task 38: Wizard scaffold + Step 1 (Your shop)
Outcome: Authed users at /welcome see a deliberate first-impression flow that captures shop context.
Routing:
/welcome → redirects to next incomplete step or "/" if done
/welcome/step-1 → "Your shop"
/welcome/step-2 → "Your PSA"
/welcome/step-3 → "Invite your team"
A top-level <WelcomeRouter /> reads users.onboarding_step_completed + users.onboarding_dismissed from authStore and dispatches:
| State | Redirect |
|---|---|
onboarding_dismissed === true |
/ |
onboarding_step_completed >= 3 |
/ |
onboarding_step_completed === null/0 |
/welcome/step-1 |
onboarding_step_completed === 1 |
/welcome/step-2 |
onboarding_step_completed === 2 |
/welcome/step-3 |
Step 1 fields (per spec):
- Company name (pre-filled from
accounts.name, editable) - Team size: select from
1-2 / 3-5 / 6-10 / 11-25 / 26+ - Your role: select from
Owner / Lead Tech / Tech / Other
Step 1 actions:
- Continue → PATCH
/users/me/onboarding-step{step: 1, action: "complete", data: {...}}→/welcome/step-2 - Skip → PATCH
{step: 1, action: "skip"}→/welcome/step-2 - Skip the rest → POST
/users/me/onboarding-dismiss-rest→/
Acceptance criteria:
- Each navigation persists state server-side before transition; refresh resumes correctly.
- Skip-the-rest is a quiet text link, not a primary button.
- Email-verification banner is visible above the wizard if user is unverified (banner persists into wizard).
Integration tests (Playwright):
new user lands on /welcome/step-1 after registerstep-1 Continue with all fields filled persists and advancesstep-1 Skip-the-rest dismisses and lands on /refresh in middle of step-1 returns to step-1 with prior data still in form (or empty if not yet saved)
Commit: feat(onboarding): add welcome wizard scaffold + Step 1 (Your shop)
Task 39: Wizard Steps 2 (Your PSA) and 3 (Invite team)
Outcome: Wizard is complete; users can finish or skip individual steps.
Step 2 fields (per spec):
- PSA selection: tiles for
ConnectWise / Autotask / HaloPSA / No PSA yet. Selecting one shows a quiet inline "Connect now" link that navigates to/account/integrations(out of wizard).
Step 3 fields (per spec):
- Email input rows × 3, with "+ Add another" up to 10 max
- Per-row role select: default "Tech" (maps to
engineer), with "Viewer" option - "Skip" and "Skip the rest" links
Step 3 submit behavior:
- POST
/api/v1/accounts/me/invites/bulkwith the populated rows. - Then PATCH
/users/me/onboarding-step{step: 3, action: "complete"}. - On success →
/?welcome=true(shows a "You're all set" toast). - Bulk endpoint's
failed[]array displayed inline next to the failed email; user can retry.
Acceptance criteria:
- Step 2 default action is "Continue" (not "Connect now"); the inline credential entry is intentionally NOT in the wizard.
- Step 3 invites are sent (email send happens server-side per Phase 1 Task 22).
- Empty Step 3 + Skip = no invites sent; step still advances.
- Each step's persistence is independent — navigating back via browser back button respects
onboarding_step_completed.
Integration tests (Playwright):
step-2 select ConnectWise → continue → primary_psa is set in /billing/state-equivalent or /auth/mestep-3 enter 2 emails → invites visible in /accounts/me/invites + emails sentstep-3 with one bad email shows partial success, user can retrywizard end-to-end: register → step-1 → step-2 → step-3 → dashboard with success toast
Commit: feat(onboarding): add wizard Steps 2 (PSA) and 3 (Invite team)
Phase M — Dashboard redesign
Task 40: Topbar trial pill + email verification banner integration
Outcome: Every authed page shows the right billing-state pill in the topbar.
Contract — <TrialPill /> placement:
Mounts inside AppLayout topbar. Reads useTrialBanner():
| Stage | Pill |
|---|---|
pristine |
"Pro trial · Nd" — info color |
warning (≤3d) |
"Pro trial · Nd" — warning amber |
urgent (≤1d) |
"Pro trial · today" — urgent (warning amber, slightly more saturated) |
expired |
"Trial expired — pick a plan" — clickable → /account/billing/select-plan |
paid |
tier display name (e.g., "Pro") — quiet |
complimentary |
"Complimentary Pro" — friendly tag, no CTA |
past_due |
"Payment failed — update card" — clickable → /account/billing |
canceled |
"Reactivate" — clickable → /account/billing/select-plan |
null |
hidden |
Acceptance criteria:
- Color tokens are existing design-system tokens (
--accent/--warning/ etc.) — no custom colors. - Pill is keyboard-focusable for clickable variants.
- EmailVerificationBanner from Task 37 sits BELOW the topbar, ABOVE main content. Both can coexist.
- Mobile: pill collapses to icon + tooltip when topbar is too narrow.
Integration tests (Playwright):
complimentary user sees "Complimentary Pro" pilltrialing user with 12 days remaining sees "Pro trial · 12d"expired-trial user sees clickable "Trial expired" pillpast_due user sees clickable "Payment failed" pill
Commit: feat(dashboard): add TrialPill in AppLayout topbar
Task 41: Next-step card + checklist redesign + dashboard wiring
Outcome: Dashboard surfaces a single "next thing to do" card; full checklist available behind a toggle. Replaces the existing OnboardingChecklist component.
Contract:
<NextStepCard />at top of dashboard content (below banner). Reads from existing/users/onboarding-statuspayload (extended in Phase 1 to drop SOLO/TEAM split — see Phase 1 Task wiring if needed; if not done, do it here).- Shows the highest-priority incomplete item with a primary CTA button. Items in priority order:
- Verify your email (only if unverified — hidden for OAuth signups)
- Set up your shop (
onboarding_step_completed >= 1) - Run your first FlowPilot session (existing
ran_sessioncheck) - Connect your PSA (existing
connected_psacheck) - Invite a teammate (extend existing
invited_teammatecheck) - Pick a plan — surfaces near trial end (only when stage is
warning/urgent/expired)
- Below the card, "Show all setup steps" toggle expands a full checklist view (single list, no SOLO/TEAM split per spec).
OnboardingChecklist component changes:
- Remove
SOLO_ITEMS/TEAM_ITEMSsplit — single unified list. - Drop the stale
tried_ai_assistant/ "Check out the Script Builder" item entirely. - Add "Pick a plan" item that shows when trial-banner stage is
warningor later.
Backend addition:
/api/v1/users/onboarding-status (existing endpoint) response shape extended:
class OnboardingStatus(BaseModel):
# existing
created_flow: bool
ran_session: bool
exported_session: bool
invited_teammate: bool
connected_psa: bool
is_team_user: bool # KEEP — internal logic only; no UI bifurcation
dismissed: bool # users.onboarding_dismissed
# NEW
email_verified: bool
shop_setup_done: bool # users.onboarding_step_completed >= 1
# REMOVED from new code paths (kept in payload for backward-compat during deploy):
# tried_ai_assistant: bool
Acceptance criteria:
- Old
OnboardingChecklistwidget is replaced wholesale on the dashboard route. Other pages that referenced it (none found in current code, but confirm via grep) are updated or unaffected. - Next-step card disappears when all items are done OR
onboarding_dismissed=TRUE. - No SOLO/TEAM bifurcation in the checklist UI.
- Stale "Script Builder" item is gone.
Integration tests (Playwright):
dashboard for new user surfaces "Verify your email" as next stepafter verifying, next step is "Set up your shop"after wizard step 1, next step is "Run your first FlowPilot session""Show all setup steps" expands to a 6-item list with no SOLO/TEAM headersPick-a-plan appears at trial day 12, urgent at day 13, primary at day 14
Commit: feat(dashboard): replace checklist with next-step card + unified list
Phase N — Public surfaces
Task 42: Pricing page (B-style) at /pricing
Outcome: Public pricing page lives at /pricing, gated by feature flag.
Contract:
Public route. Component at frontend/src/pages/PricingPage.tsx. Reads plan_billing data via a new public endpoint:
GET /api/v1/plans/public
→ 200 [
{
plan: string,
display_name: string,
description: string | null,
monthly_price_cents: number | null,
annual_price_cents: number | null,
max_seats: number | null, // from plan_limits
sort_order: number,
is_public: true, // filtered server-side
},
...
]
Page sections (per spec B):
- Hero (one-liner + reverse-trial reassurance)
- Three plan cards (Starter / Pro recommended / Enterprise) — Pro card has "Recommended" badge; Enterprise card has "Talk to sales" CTA →
/contact-sales - Comparison table (which features in which plan) — driven by feature flag display names
- Single testimonial slot (placeholder until real testimonial available)
- Trust strip — security/compliance copy
Acceptance criteria:
- Returns 404 when
self_serve_enabled === false. - Plan cards show prices from
plan_billing.monthly_price_cents. Enterprise card hides price. - "Start free trial" buttons on Starter/Pro link to
/register?plan=pro(or starter). - "Talk to sales" on Enterprise links to
/contact-sales. - Trust strip claims should be honest — see spec open-risks #7 (GDPR DPA) and #7b (SOC2). If those aren't ready by cutover, copy in this task uses softer language (e.g., "Built on Stripe + AWS · Encrypted in transit and at rest").
Integration tests (Playwright):
unauth user sees pricing page when self_serve_enabled is truepricing page → "Start free trial" → /register?plan=propricing page → "Talk to sales" → /contact-salespricing page returns 404 when self_serve_enabled is false
Commit: feat(pricing): add /pricing page (B-style)
Task 43: Talk-to-sales form at /contact-sales + landing-page CTA
Outcome: Enterprise prospects have a clear path; LandingPage.tsx gets a "See pricing" CTA.
Contract:
/contact-sales route with form posting to POST /sales-leads (Phase I Task 29).
Form fields:
- Name (required)
- Work email (required)
- Company (required)
- Team size (select; same buckets as wizard Step 1 + a "more than 26" option)
- "What brought you here?" (textarea, optional)
- Submit button
After submit:
- Confirmation page: "Thanks — we'll reach out within 1 business day. Want to skip ahead? [Calendly link]"
- Calendly link is a config string (
VITE_CALENDLY_URL); when unset, link section is hidden.
LandingPage.tsx modification:
- Add a prominent "See pricing" CTA near the existing "Get started" CTA.
- Both visible regardless of
self_serve_enabled(see-pricing 404s if flag off, landing keeps existing behavior). Actually: gate the See-pricing CTA behinduseAppConfig().self_serve_enabledso we don't show a button that 404s.
Acceptance criteria:
- Form blocks duplicate submissions client-side (disable button while in flight).
- PostHog
talk_to_sales_form_submittedevent fires withsource: 'pricing_page' | 'landing_page'based on referrer. - Calendly link block hides when
VITE_CALENDLY_URLunset.
Integration tests (Playwright):
submit /contact-sales form → see confirmation page → /sales-leads has new rowlanding page shows "See pricing" CTA when self_serve_enabled, hides when off
Commit: feat(sales): add /contact-sales form + landing page CTA
Task 44: Beta-signup deprecation
Outcome: The legacy beta_signup.py endpoint redirects to register; existing waitlist gets a heads-up email.
Contract:
POST /api/v1/beta-signup(existing) → keep mounted but return307 Temporary Redirectto/register?from=beta.- One-off admin script
scripts/email_beta_waitlist.pythat reads existingbeta_signuptable and queues "we've launched" emails to each. - Don't drop the table; archive in place.
Acceptance criteria:
- Existing tests against
/beta-signupeither updated to expect 307 or removed. - Script is idempotent (uses an
email_sent_atfield on the beta-signup row, adding it via migration if needed).
Integration tests:
POST /beta-signup returns 307 to /register?from=beta
Commit: feat(sales): redirect beta-signup to /register; queue waitlist emails
Phase O — Cutover
Task 45: Stripe live-mode setup checklist (manual)
Outcome: Stripe live-mode is configured and matches test mode. Manual step; this task tracks completion.
Checklist:
- In Stripe Dashboard (live mode):
- Create Products: ResolutionFlow Starter, ResolutionFlow Pro, ResolutionFlow Enterprise.
- Create monthly + annual recurring Prices for Starter and Pro.
- Enterprise has no Prices in the catalog (sales-created per customer).
- Enable Customer Portal: 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 events:checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.payment_failed,invoice.payment_succeeded. - Save the live webhook signing secret.
- In Railway prod environment variables:
STRIPE_SECRET_KEY(live mode key,sk_live_...)STRIPE_WEBHOOK_SECRET(live signing secret)STRIPE_PUBLISHABLE_KEY(live publishable key) →VITE_STRIPE_PUBLISHABLE_KEYfor frontend buildsOAUTH_REDIRECT_BASE=https://resolutionflow.comGOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETfor prod Google OAuth app (separate from dev/test)MS_CLIENT_ID/MS_CLIENT_SECRETfor prod Microsoft OAuth app
- Run
python -m scripts.sync_stripe_plan_ids(Phase 1 Task 6 referenced; create if not existing) to populateplan_billingrows with live Stripe IDs:- Pro monthly + annual price IDs
- Starter monthly + annual price IDs (if Starter is in scope; see open risk #14)
- Enterprise: stripe_product_id only, no price IDs
Acceptance criteria:
- Live webhook receives a test event (use Stripe CLI's
stripe trigger checkout.session.completedagainst the live endpoint with a test customer) and is logged instripe_events. plan_billingrows query returns expected Stripe IDs for Pro tier.
No commit — this is a deploy-time operation.
Task 46: Internal validation pass (test mode → soft cutover via per-email allowlist)
Outcome: Real flow exercised end-to-end against the prod backend with SELF_SERVE_ENABLED=false, gated to internal testers only.
Per-email allowlist mechanism:
Backend reads INTERNAL_TESTER_EMAILS env var (comma-separated). When SELF_SERVE_ENABLED=false AND current_user.email is in the list, treat the user as if the flag were on (e.g., bypass invite-code requirement, expose pricing page via a header check). For frontend, the /config/public endpoint returns self_serve_enabled: true for these specific authenticated users.
Validation scenarios:
- Email signup → wizard step-by-step → first FlowPilot session run → trial-end synthetic time (DB query:
UPDATE subscriptions SET current_period_end = now() - interval '1 day' WHERE account_id = ...) → plan picker → Stripe Checkout (test card4242 4242 4242 4242) → webhook → status='active'. - Google sign-in (real Google account) →
/welcome→ wizard → dashboard. - Microsoft sign-in (real M365 account) → same flow.
- Invite-by-email: existing tester invites a teammate → teammate receives email → clicks link →
/accept-invite→ set password → joins account → lands on/?welcome=teammate. - Email match enforcement: try to register with
account_invite_codeand a different email → seeinvite_email_mismatch. - Past-due simulation: use Stripe test card
4000 0000 0000 0341→ first invoice succeeds, next charge declines →subscription_status='past_due'→ topbar pill changes → user can update card via Customer Portal. - Pilot complimentary: log in as an existing pilot account → see "Complimentary Pro" pill, no walls, no nudges.
- Webhook signature failure: send a forged webhook → 400 + log entry.
- OAuth-only user attempts password login: rejected with
use_oauth_provider.
Acceptance criteria:
- All 9 scenarios pass in prod test mode with internal testers.
- Errors logged during validation are reviewed and either fixed or documented.
No commit — validation is a checklist of test runs.
Task 47: Feature-flag flip + week-1 monitoring
Outcome: SELF_SERVE_ENABLED=true and VITE_SELF_SERVE_ENABLED=true in prod. Public pricing page is live.
Cutover steps:
- Send pre-launch email to all pilot users via
EmailService.send_complimentary_account_announcement(1-2 days before flip). - Schedule the flip during low-traffic hours.
- Update Railway env vars:
SELF_SERVE_ENABLED=true(backend),VITE_SELF_SERVE_ENABLED=true(frontend, requires redeploy since Vite bakes at build time). - Verify prod: pricing page returns 200; new user can register without invite code.
- Announce launch (founder action; not eng).
Week-1 monitoring (PostHog dashboards):
- Funnel:
pricing_page_viewed → register_started → register_completed → email_verification_completed → welcome_wizard_completed → first_session_started - OAuth method mix
- Wizard skip rate per step
feature_gate_blockedcount byflag_key- Trial conversion:
trial_modal_shown → checkout_completed - Stripe webhook error rate (Sentry alert if > 1/hour)
subscriptions.is_paidaudit query (manual SQL): confirm complimentary accounts are NOT counted in MRR
Rollback plan:
- Flip both flags back to
false. Pricing page → 404. Register page → invite-code-required flow. Pilot complimentary status preserved (benign). - Stripe webhook handler stays live regardless.
- Forward-only schema means nothing to revert at the DB level.
No commit — this is a deploy + monitor task.
Self-Review
Spec coverage check (against 2026-05-05-self-serve-signup-onboarding-design.md):
| Spec section | Covered by |
|---|---|
| §3.1 Pricing page | Task 42 |
| §3.2 Register page redesign with OAuth + invite-code-optional | Task 35 |
| §3.3 Welcome wizard (3 steps) | Tasks 38, 39 |
| §3.4 Dashboard with topbar pill + next-step card | Tasks 40, 41 |
| §3.5 Email verification surfaces | Task 37 |
| §3.6 Trial-end conversion (in-app modal day 10, wall day 14) | Task 41 covers checklist; the modal is part of Task 40's TrialPill stage transitions + the dashboard's modal trigger via useTrialBanner — implementer's discretion to add a <TrialEndingModal /> component if it emerges naturally |
| §3.7 Plan picker → Stripe Checkout | Frontend page at /account/billing/select-plan lives within the dashboard area; Task 41's "Pick a plan" CTA navigates there. Component exists in scope of Task 40/41 — implementer's call on whether to split into its own file. |
| §3.8 Past-due / dunning | Task 40 (TrialPill past_due stage) + Customer Portal link |
| §3.9 Sales lead | Tasks 29, 43 |
| §3.10 Owner transfer (existing) | No new task — surface in Account → Team page during dashboard work, implementer's discretion |
| §4 BillingService.open_customer_portal | Task 27 |
| §4 PATCH /users/me/onboarding-step | Task 28 |
| §4 GET /billing/state consumed by frontend | Task 32 |
| §4 useFeature/useFeatureLimit/useTrialBanner | Task 33 |
| §4 FeatureGate / UpgradePrompt | Task 34 |
| §4 Caching invalidation triggered from /admin/plan-limits | Task 30 |
| §5 Beta-signup deprecation | Task 44 |
| §5 SELF_SERVE_ENABLED dark launch | Task 31 |
| §5 Stripe live-mode setup | Task 45 |
| §5 Internal validation phase | Task 46 |
| §5 Cutover + monitoring | Task 47 |
Gaps and judgment-calls (called out for implementer):
<TrialEndingModal />(day-10 in-app modal) — left to implementer to decide whether it's its own task or rolled into Task 40. Spec is clear about behavior; component split is style.- Plan picker page (
/account/billing/select-plan) — frontend page that callsPOST /billing/checkout-sessionand redirects. Lives within Task 40/41 area; not its own task. Acceptance: "user can pick Starter/Pro + seats and be redirected to Stripe Checkout." - Owner-transfer surface in Account → Team page — existing endpoint, just needs UI. Implementer's call on which task absorbs this.
<TrialEndedWall />— referenced in spec; renders on dashboard route when trial expired. Lives in Task 40/41 area as a render-branch of the dashboard layout.
Placeholder scan: none — every "implementer's discretion" call is bounded by a contract and acceptance criteria.
Type/contract consistency:
BillingStateshape in Task 32 matchesBillingStateResponsefrom Phase 1 Task 24.PATCH /users/me/onboarding-steppayload in Task 28 matches the wizard's writes in Tasks 38, 39.- OAuth callback contract in Task 35 matches Phase 1 Task 17/18 endpoint shapes.
<EmailVerificationGate>in Task 34 reads from authStore;<TrialPill>in Task 40 reads fromuseBillingStore. Different sources, intentional (verification is onUser, trial is onSubscription).
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md.
This plan is intentionally higher-altitude than Phase 1: contracts and acceptance criteria, not component-detail walkthroughs. Implementers exercise judgment on internal structure as long as contracts hold and integration tests pass.
Recommended execution sequence:
- Phase 1 first (
2026-05-06-self-serve-signup-phase-1-backend.md). Phase 2 depends on its endpoints. - After Phase 1 lands, execute Phase 2 phases I → O sequentially. Each phase is one or a few mergeable PRs.
- Cutover (Phase O) is gated by Phase 1 + Phase 2 both green in prod test mode.
Two execution options for Phase 2:
1. Subagent-Driven (recommended) — fresh subagent per task with two-stage review. Higher-altitude tasks pair well with this since the subagent has room to make local design decisions inside the contract.
2. Inline Execution — execute tasks in a long-running session using executing-plans, with checkpoints between phases.
Which approach?