Files
resolutionflow/docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md
Michael Chihlas ab0d40c1e2 docs(plan): self-serve signup & onboarding implementation plans
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>
2026-05-06 19:14:30 -04:00

45 KiB
Raw Blame History

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 2731 Backend endpoints Phase 1 deferred + SELF_SERVE_ENABLED flag + /admin/plan-limits extension
J 3234 Frontend billing foundation: useBillingStore, hooks, gating components — proven against Phase 1 backend
K 3537 Auth surfaces: register redesign with OAuth buttons, accept-invite page, email-verification surfaces
L 3839 Welcome wizard — 3 steps with persistence
M 4041 Dashboard redesign — trial pill, next-step card, checklist redesign
N 4244 Public surfaces: pricing page, contact-sales form, landing-page CTA, beta-signup 307
O 4547 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-session and is in the _SUBSCRIPTION_GUARD_ALLOWLIST and _EMAIL_VERIFICATION_ALLOWLIST (so it works for canceled / unverified-past-grace users who need to update billing).
  • Returns 400 with {"error": "no_stripe_customer"} when account.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=1 only (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 step is 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_role
  • test_onboarding_step2_skip_advances_without_psa
  • test_onboarding_step_cannot_decrease
  • test_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:

  1. Insert sales_leads row.
  2. Fire-and-forget EmailService.send_sales_lead_notification to settings.SALES_LEAD_RECIPIENT_EMAIL (new env var, default sales@resolutionflow.com).
  3. Emit PostHog server-side event talk_to_sales_form_submitted with source property.

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_email
  • test_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_limits and plan_billing in one transaction.
  • Cache invalidation: app.state.billing_cache flushed for all accounts on the affected plan.
  • No new admin page in v1 — existing /admin/plan-limits UI just gets new form fields.

Integration tests:

  • test_admin_plan_limits_get_includes_plan_billing_fields_when_present
  • test_admin_plan_limits_put_creates_plan_billing_row
  • test_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_ENABLED env var (build-time bake-in per Lesson 60).
  • New useAppConfig hook: prefers backend /config/public response, falls back to VITE_SELF_SERVE_ENABLED for build-time gating.
  • Public routes (/pricing, /contact-sales, /accept-invite, OAuth callbacks) return 404 from the frontend router when self_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/register invite-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_flag
  • test_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 useInterval in 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/state triggers 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 subscription
  • useBillingStore resets on logout
  • useBillingStore 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'complimentary
  • subscription.status === 'active'paid
  • subscription.status === 'past_due'past_due
  • subscription.status === 'canceled'canceled
  • subscription.status === 'trialing' AND current_period_end > now()pristine (>3 days), warning (13), urgent (<1)
  • subscription.status === 'trialing' AND current_period_end <= now()expired

Acceptance criteria:

  • useFeatureLimit does NOT block render — returns isLoading=true until usage data arrives.
  • useTrialBanner returns null when subscription is null (no flicker on initial load).
  • All three hooks subscribe to useBillingStore such that updates propagate without manual refetch.

Integration tests (Vitest):

  • useFeature returns false when flag absent
  • useFeatureLimit transitions isLoading → loaded
  • useTrialBanner 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 from useFeature(feature). Server-side check via require_feature is the security boundary; this is UX.
  • <UpgradePrompt> CTA links to /account/billing/select-plan.
  • <EmailVerificationGate> reads users.email_verified_at + users.created_at from authStore.user. Day 16 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 (no bg-accent for 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 code to POST /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.com etc.) can still log in via /login unchanged.

Integration tests (Playwright):

  • register email+password → verification email queued → land on /welcome
  • register via Google OAuth (mocked provider) → land on /welcome
  • register 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:

  1. 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).
  2. Render: "Join {account_name} on ResolutionFlow" + email locked to invited_email + three sign-in options (set password, Google, Microsoft).
  3. On submit, POST to existing /auth/register with account_invite_code and the email matching invited_email (per Phase 1 Task 20 enforcement).
  4. OAuth path: launch provider with state including the invite code; callback POSTs {code, account_invite_code, invited_email} to handle linking.
  5. 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 same invite_email_mismatch error 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=teammate
  • accept invite with Google OAuth (matching email) → land on dashboard
  • accept invite with mismatched email → see invite_email_mismatch error
  • accept 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 when users.email_verified_at IS NULL AND grace not expired. "Resend" link calls existing POST /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 existing POST /auth/email/verify and shows success/error state. On success, refreshes the auth store and redirects to /?verified=1 toast.

Acceptance criteria:

  • Banner contrasts well in dark theme (use bg-warning-dim per 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 dashboard
  • unverified day-8 user sees wall, can sign out, can resend
  • clicking 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 register
  • step-1 Continue with all fields filled persists and advances
  • step-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/bulk with 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/me
  • step-3 enter 2 emails → invites visible in /accounts/me/invites + emails sent
  • step-3 with one bad email shows partial success, user can retry
  • wizard 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" pill
  • trialing user with 12 days remaining sees "Pro trial · 12d"
  • expired-trial user sees clickable "Trial expired" pill
  • past_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-status payload (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:
    1. Verify your email (only if unverified — hidden for OAuth signups)
    2. Set up your shop (onboarding_step_completed >= 1)
    3. Run your first FlowPilot session (existing ran_session check)
    4. Connect your PSA (existing connected_psa check)
    5. Invite a teammate (extend existing invited_teammate check)
    6. 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_ITEMS split — 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 warning or 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 OnboardingChecklist widget 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 step
  • after 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 headers
  • Pick-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):

  1. Hero (one-liner + reverse-trial reassurance)
  2. Three plan cards (Starter / Pro recommended / Enterprise) — Pro card has "Recommended" badge; Enterprise card has "Talk to sales" CTA → /contact-sales
  3. Comparison table (which features in which plan) — driven by feature flag display names
  4. Single testimonial slot (placeholder until real testimonial available)
  5. 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 true
  • pricing page → "Start free trial" → /register?plan=pro
  • pricing page → "Talk to sales" → /contact-sales
  • pricing 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 behind useAppConfig().self_serve_enabled so 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_submitted event fires with source: 'pricing_page' | 'landing_page' based on referrer.
  • Calendly link block hides when VITE_CALENDLY_URL unset.

Integration tests (Playwright):

  • submit /contact-sales form → see confirmation page → /sales-leads has new row
  • landing 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 return 307 Temporary Redirect to /register?from=beta.
  • One-off admin script scripts/email_beta_waitlist.py that reads existing beta_signup table and queues "we've launched" emails to each.
  • Don't drop the table; archive in place.

Acceptance criteria:

  • Existing tests against /beta-signup either updated to expect 307 or removed.
  • Script is idempotent (uses an email_sent_at field 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/stripe with 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_KEY for frontend builds
    • OAUTH_REDIRECT_BASE = https://resolutionflow.com
    • GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET for prod Google OAuth app (separate from dev/test)
    • MS_CLIENT_ID / MS_CLIENT_SECRET for prod Microsoft OAuth app
  • Run python -m scripts.sync_stripe_plan_ids (Phase 1 Task 6 referenced; create if not existing) to populate plan_billing rows 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.completed against the live endpoint with a test customer) and is logged in stripe_events.
  • plan_billing rows 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 card 4242 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_code and a different email → see invite_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_blocked count by flag_key
  • Trial conversion: trial_modal_shown → checkout_completed
  • Stripe webhook error rate (Sentry alert if > 1/hour)
  • subscriptions.is_paid audit 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 calls POST /billing/checkout-session and 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:

  • BillingState shape in Task 32 matches BillingStateResponse from Phase 1 Task 24.
  • PATCH /users/me/onboarding-step payload 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 from useBillingStore. Different sources, intentional (verification is on User, trial is on Subscription).

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:

  1. Phase 1 first (2026-05-06-self-serve-signup-phase-1-backend.md). Phase 2 depends on its endpoints.
  2. After Phase 1 lands, execute Phase 2 phases I → O sequentially. Each phase is one or a few mergeable PRs.
  3. 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?