8 Commits

Author SHA1 Message Date
25d124c6eb wip(handoff): pr #164 cutover blockers + doc refresh + dns triage
All checks were successful
Mirror to GitHub / mirror (push) Successful in 6s
CI / frontend (pull_request) Successful in 6m44s
CI / e2e (pull_request) Successful in 10m12s
CI / backend (pull_request) Successful in 10m33s
Updates HANDOFF.md, CURRENT_TASK.md, SESSION_LOG.md to reflect this
session's work: PR #164 ready for review (5 commits closing the last
self-serve cutover code blockers), Phase O manual-ops sequence as the
resume point, and the apex-DNS / Edge-HSTS issues open on the user's
side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:12:05 -04:00
2c9f5e95ff fix(frontend): page-title — escapes + propagate plan taxonomy through frontend types
Some checks failed
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (pull_request) Failing after 6m1s
CI / frontend (pull_request) Successful in 6m23s
CI / backend (pull_request) Successful in 9m55s
Two fixes that surfaced together:

1. LandingPage.tsx had `—` in a JSX attribute string — JSX attribute
   strings don't process JS escape sequences, so the literal six-character
   "—" was rendering in the browser tab title and OG description
   instead of the intended em dash. Replaced with the literal em dash
   character. Same pattern was previously valid because every other use
   of `\u...` in the codebase is inside a JS string (regular `'...'`
   string literal in TS, or `{...}` expression in JSX), where escapes
   resolve at compile time. Verified by grep — LandingPage was the only
   site with the bug.

2. PageMeta default fallback tagline was "Decision Tree Platform" — a
   stale tagline from before the FlowPilot pivot. Updated to the current
   "AI-Powered Troubleshooting for MSPs" (matches index.html and brand
   positioning). Default branch is rarely hit since every page passes
   a title, but cleaner.

While building, hit TS errors that revealed the prior taxonomy commit
(team -> enterprise + add starter) didn't propagate through the frontend.
Cleared all of them:

- types/account.ts, types/admin.ts: Subscription.plan, AdminAccountCreate.plan,
  InviteCodeCreateRequest.assigned_plan literals updated to the new tax.
- types/billing.ts: dropped 'team' from CheckoutPlan (was hybrid old+new).
- admin/AccountsPage.tsx, admin/InviteCodesPage.tsx: state-type literals,
  select onChange casts, and the visible <option> rows updated. PLAN_OPTIONS
  in InviteCodesPage now has all four tiers with correct labels.
- AccountSettingsPage.tsx: `plan !== 'team'` -> `'enterprise'`, CheckoutButton
  prop value too.
- subscription/CheckoutButton.tsx: prop type was 'pro' | 'team', updated
  to 'starter' | 'pro' | 'enterprise' with matching planLabels.

Verified: tsc -b clean, lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:07:51 -04:00
8649a4aa29 docs: refresh CURRENT-STATE, ROADMAP, README, DECISIONS for self-serve cutover
Some checks failed
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (pull_request) Failing after 1m55s
CI / frontend (pull_request) Failing after 2m36s
CI / backend (pull_request) Successful in 9m46s
Pulls the public docs forward to match the current state of the repo. No
behavior changes — every edit is informational.

- CURRENT-STATE.md: bump date to 2026-05-07; add entries for PR #159 (Diátaxis
  User Guides), #160 (sidebar IA + account redesign), #161 (self-serve Phase 1
  backend), #162 (Phase 2 frontend cutover), #163 (seed users email-verified),
  #164 (open: taxonomy + INTERNAL_TESTER_EMAILS allowlist). Refresh "What's
  In Progress" and "What's Next" to reflect Phase O cutover as the active work.
- 03-DEVELOPMENT-ROADMAP.md: add a "Status as of 2026-05-07" preamble at the
  top so the months-stale historical content underneath is clearly framed as
  historical record. Replace stale "In Progress" rows (PR #114, ConnectWise
  Advanced) with current ones (#164 cutover, external Director-of-Onboarding
  validation calls). Add Phase O cutover checklist as the new near-term
  priority section. Mark search-and-recall complete (shipped via Voyage AI
  embeddings).
- README.md: replace `docker start patherly_postgres` (legacy container name)
  with `docker compose -f docker-compose.dev.yml up -d`. Repath project tree
  from `patherly/` to `resolutionflow/` and add `.ai/` + `scripts/` directories.
  Replace `UI-DESIGN-SYSTEM.md` (superseded) with `DESIGN-SYSTEM.md` in the
  documentation table; add `AGENTS.md`, `PROJECT_CONTEXT.md`, `PRODUCT.md`.
- DECISIONS.md: append entries for the two architectural decisions made today
  — plan taxonomy reconciliation (rename team→enterprise, add starter) and
  the INTERNAL_TESTER_EMAILS allowlist for self-serve soft cutover.
- .env.example: add INTERNAL_TESTER_EMAILS line (user edit, paired with the
  backend allowlist that landed in the prior commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:56:01 -04:00
8494366ec6 feat(billing): add INTERNAL_TESTER_EMAILS allowlist for self-serve soft cutover
Some checks failed
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (pull_request) Failing after 1m57s
CI / frontend (pull_request) Failing after 2m35s
CI / backend (pull_request) Successful in 9m46s
Phase O Task 46 needs internal validation of the full self-serve flow
against the prod backend before flipping SELF_SERVE_ENABLED public. This
adds the per-email allowlist that bypasses the global flag for specific
authenticated users.

- INTERNAL_TESTER_EMAILS: comma-separated list, parsed by a Pydantic
  field_validator into a normalized lowercase list. Settings.is_internal_tester
  and Settings.is_self_serve_active_for centralize the allowlist + global-flag
  check; both endpoints below call the latter.
- New get_current_user_optional dep — best-effort auth that returns None
  on missing/invalid token instead of 401. Used by /config/public so the
  same endpoint serves anonymous public callers and authenticated allowlist
  members.
- /config/public now accepts optional auth and returns self_serve_enabled=True
  for authenticated allowlist members even when the global flag is off.
  Anonymous callers always see the global flag.
- /auth/register replaces the SELF_SERVE_ENABLED check with the helper so a
  registering email on the allowlist can join without an invite code.
  Non-allowlist emails still 400 when self-serve is off.
- docker-compose.dev.yml passes SELF_SERVE_ENABLED + INTERNAL_TESTER_EMAILS
  through; backend/.env.example documents both.

Tests cover: allowlisted authenticated user sees true, non-allowlisted
authenticated user sees the global flag, anonymous calls ignore the
allowlist, allowlisted email registers without invite code, non-allowlisted
email still blocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:57:25 -04:00
a628b2410d chore(dev): pass STRIPE_* env to backend container; add repo-root .env.example
The backend container had no Stripe env vars wired through compose, so
sync_stripe_plan_ids.py and any in-app Stripe calls would short-circuit
even when sk_test_ was set in the repo .env. Adds STRIPE_SECRET_KEY,
STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET pass-throughs.

Also flips REQUIRE_INVITE_CODE to false in the dev compose (matches the
working state on this machine — Phase 2 self-serve has been gating that
behavior on SELF_SERVE_ENABLED + the upcoming INTERNAL_TESTER_EMAILS
allowlist anyway).

Adds a repo-root .env.example documenting the variables compose itself
reads (REPO_ROOT, POSTGRES_PORT, secrets) — separate from
backend/.env.example which documents the backend service env.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:59:57 -04:00
ba36c47075 feat(billing): reconcile plan taxonomy and add Stripe sync script
The marketing surface (PricingPage, Stripe products) was wired for
"Starter / Pro / Enterprise" while the backend was on "free / pro / team",
leaving plan_billing unseeded and BillingPlan accepting a literal that
violated the FK to plan_limits.

This change:

- Migration 4ce3e594cb87: defensive UPDATE of any subscriptions on
  plan='team' to 'enterprise' (dev has zero), renames the plan_limits
  row team -> enterprise, inserts a starter row with caps interpolated
  between free and pro (max_trees=10, sessions=75, ai=15/mo).
- Renames the plan tier across schemas (invite_code, billing, admin,
  subscription comment), is_paid/has_pro_entitlement checks in the
  Subscription model, admin/admin_dashboard plan validators, and the
  frontend useSubscription isPaidPlan check. Resource visibility uses
  the same string 'team' in a separate domain (Tree/StepLibrary
  visibility) and is intentionally untouched.
- New backend/scripts/sync_stripe_plan_ids.py: idempotent upsert of
  plan_billing rows from Stripe products by exact name match. Picks
  the active monthly recurring price for tiers that have one; leaves
  annual fields NULL by design. Works against test or live keys.
- Test fixture updates: conftest seeds the new taxonomy, the public
  plans helper is a true upsert so tests can override max_users, and
  team -> enterprise across test_admin_plan_limits and test_invite_plan.

Verified: 86/86 passing across the subscription/billing/plan/invite/
admin sweep; sync script run against test mode populates plan_billing
correctly for all three tiers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:59:42 -04:00
dad5e1f546 fix(seed): mark seeded test users as email-verified (#163)
All checks were successful
CI / frontend (push) Successful in 6m46s
Mirror to GitHub / mirror (push) Successful in 6s
CI / backend (push) Successful in 10m39s
CI / e2e (push) Successful in 10m16s
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-07 18:42:32 +00:00
f1be3abcc5 feat: self-serve signup Phase 2 (frontend cutover) (#162)
Some checks failed
CI / e2e (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-07 18:42:20 +00:00
80 changed files with 4030 additions and 253 deletions

View File

@@ -1,9 +1,12 @@
# CURRENT_TASK.md
**Active task:** None — pick next from `.ai/TODO.md` or `03-DEVELOPMENT-ROADMAP.md`.
**Active task:** Phase O cutover for self-serve signup. PR #164 (`feat/billing-plan-taxonomy`) open in Gitea with 5 commits at head `2c9f5e9`, closing the last code blockers — plan taxonomy reconciliation (`team``enterprise`, add `starter`), `INTERNAL_TESTER_EMAILS` allowlist, `sync_stripe_plan_ids.py` script, page-title `—` JSX-escape bug fix, frontend taxonomy followups, doc refresh. After merge, only manual ops remain: Stripe Dashboard live-mode config, Railway prod env vars, internal validation pass, public flag flip. See `.ai/HANDOFF.md` for the resume point.
## Recently shipped
- **2026-05-08 — PR #164 (open)** Plan taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist + Stripe sync script + page-title fix + frontend taxonomy followups + doc refresh. 5 commits on `feat/billing-plan-taxonomy` from main (`dad5e1f`); HEAD `2c9f5e9`. Migration `4ce3e594cb87` renames `plan_limits.plan='team'``'enterprise'` and adds `starter` row (caps interpolated between free and pro: `max_trees=10`, `sessions=75`, `ai=15/mo`). Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched. New `backend/scripts/sync_stripe_plan_ids.py` upserts `plan_billing` rows from Stripe products by exact name match — annual fields stay NULL by design (user explicitly skipping annual pricing for exit flexibility). `Settings.is_internal_tester` + `is_self_serve_active_for` centralize the allowlist + global-flag check; new `get_current_user_optional` dep; `/config/public` honors allowlist for authenticated callers; `/auth/register` allows allowlisted emails without invite code. LandingPage page-title bug — `—` inside JSX attribute strings was rendering as 6 literal characters in browser tabs; replaced with literal em dash. PageMeta default tagline updated from "Decision Tree Platform" to "AI-Powered Troubleshooting for MSPs". 86/86 passing across subscription/billing/plan/invite/admin sweep; tsc + lint clean. See `.ai/DECISIONS.md` for the two architectural entries (taxonomy reconciliation, allowlist).
- **2026-05-06 — PR #163** Seed test users marked email-verified. Squash-merged into main as `dad5e1f`.
- **2026-05-06 — PR #162** Self-serve signup Phase 2 (frontend cutover). 18 commits across Tasks 2744 of the plan. Backend remainders + frontend billing foundation + auth surfaces (OAuth + accept-invite + verify-email) + welcome wizard + dashboard redesign (TrialPill, NextStepCard, unified checklist) + public surfaces (`/pricing`, `/contact-sales`) + beta-signup deprecation. Squash-merged into main as `f1be3ab`. Single alembic head was `c6cbfc534fad` (no new migrations in Phase 2; PR #164 adds `4ce3e594cb87`).
- **2026-05-02 — PR #159** In-product User Guides rewrite. Merged into `main`. Replaced 15 feature-dump guides with 43 problem-oriented Diátaxis how-tos grouped under 10 categories. Dropped Maintenance Flows / AI Assistant / Flow Assist Sparkles (UI no longer exists). Renamed Step Library → Solutions Library. Authored 14 net-new how-tos for FlowPilot-era surfaces (tasklane keyboard flow, what-we-know, resolve, escalate, record-fix-outcome, post-docs-to-ticket, share-update, pause-and-leave, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate, etc.). Schema additions: `category`, optional `relatedSlugs`; hub renders category sections; detail page renders related-guides footer. Fixed rendering bug where `**bold**` in `step.tip` rendered literally. Killed misleading "N sections" subtitle on guide cards. Browser-verified against engineer + owner login (sidebar labels, account sub-pages, pilot-screen header buttons, Tasks panel, integration form). Two unverified items intentionally deferred: change-teammate-role (requires non-owner test member to inspect role-change control) and detailed Resolve / Escalate modal contents (Resolve gated by 6 pending tasks in test data). tsc and Vite build clean.
- **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Merged into `main` as `5e10005`.
- **Impeccable pass** (5 sub-passes — distill / quieter / layout / typeset / polish): score 24/40 → 33/40. Removed the duplicate "Suggested checks" chip strip; added an inline `Next steps · N pending in Tasks` cue above the latest action-bearing AI bubble; consolidated the desktop session header to Resolve + Escalate + ⋯ kebab (Context / New Ticket / Update Ticket / Pause now under the kebab, mobile kebab gained Context + New Ticket parity); centered the messages column to `max-w-3xl` to match the composer; bubbles dropped to `rounded-xl`. Decoration sweep: dropped 3px side stripes (TaskLane done states, all 6 ProposalBanner modes, WhatWeKnowItem rows), gradient backgrounds (WhatWeKnow + every banner), accent borderTop on TaskLane header, backdrop-blur on handoff overlay, animate-pulse-amber ring in VerifyingBanner, bordered avatar boxes in banners. Type sweep: 14 distinct sizes → 5-step scale (10/11/12/13/14px). Icon disambiguation: `MessageCircleQuestion` split into `Pencil` (Answer CTA) + `HelpCircle` (per-check explainer). Dead `font-sans` audit (12 sites) and double `text-xs` cleanups.

View File

@@ -13,6 +13,64 @@
---
## 2026-05-07 — Per-email allowlist (`INTERNAL_TESTER_EMAILS`) for self-serve soft cutover
**Context:** Phase O Task 46 ("internal validation pass") needed a way to exercise the full self-serve flow against the prod backend before flipping `SELF_SERVE_ENABLED=true` for everyone. The plan doc described the mechanism but the backend support was never built — flagged in `SESSION_LOG.md` as a code blocker. Stripe live-mode setup is also gated on having a working internal-tester path in prod test mode.
**Decision:** Comma-separated allowlist `INTERNAL_TESTER_EMAILS` parsed by a Pydantic field_validator into a normalized lowercase list. Two helpers on `Settings`: `is_internal_tester(email)` (case-insensitive membership check) and `is_self_serve_active_for(email)` (returns `SELF_SERVE_ENABLED OR is_internal_tester(email)`). Both endpoints that gate on the global flag now call the helper:
- `/config/public` accepts optional auth via new `get_current_user_optional` dep; returns `self_serve_enabled=true` for allowlisted authenticated callers; anonymous calls always see the global flag.
- `/auth/register` allows allowlisted emails to register without an invite code.
**Rejected:**
- **Custom header `X-Internal-Tester-Email` for anonymous flows.** Spoofable. The auth/register-payload checks are sufficient because the user has to OWN the email to register or log in.
- **Separate allowlists per surface (`INTERNAL_PRICING_TESTERS`, `INTERNAL_OAUTH_TESTERS`).** Premature splitting. The Phase O use case is "this small set of people can see the new flow"; one variable handles it. If finer granularity emerges, split then.
- **Database table for the allowlist.** Env var matches the spec from the plan doc and fits the soft-cutover lifecycle — list is small, changes infrequently, lives alongside other deployment-time config.
**Consequences:**
- Stripe internal validation can run end-to-end in prod test mode without flipping the global flag.
- Anonymous callers always see the global flag — the allowlist never leaks via unauthenticated request content. Three regression tests in `test_config_public.py` enforce this.
- `INTERNAL_TESTER_EMAILS` plumbed through `docker-compose.dev.yml` and documented in `backend/.env.example`. Railway prod env will need the same var set during Phase O cutover.
---
## 2026-05-07 — Reconcile plan tier taxonomy (rename `team` → `enterprise`, add `starter`)
**Context:** PR #162 left a real architectural gap. Marketing surface (PricingPage, Stripe products) was wired for `Starter / Pro / Enterprise` while backend was on `free / pro / team`. `plan_billing.plan` FK referenced `plan_limits.plan` so the `BillingPlan` schema's `Literal["pro", "starter", "team", "enterprise"]` could accept values that violated the FK. `plan_billing` was unseeded in dev, so no checkout could complete. `Subscription.plan.in_(["pro", "team"])` paid-plan checks wouldn't recognize `enterprise`. Self-serve cutover was blocked at the data layer.
**Decision:** Reconcile to a single taxonomy — backend slugs become `free / pro / starter / enterprise`, matching the marketing surface and Stripe products. Migration `4ce3e594cb87`:
1. Defensive `UPDATE subscriptions SET plan='enterprise' WHERE plan='team'` (dev had zero such rows; safety for any prod stragglers).
2. Rename the `plan_limits.plan='team'` row to `'enterprise'`.
3. Insert a `starter` row with caps interpolated between free and pro: `max_trees=10`, `max_sessions=75`, `max_users=1`, `max_ai_builds_per_month=15`, no KB Accelerator, no custom branding, no priority support.
Code rename across schemas, `Subscription` paid-plan/`has_pro_entitlement` checks, admin endpoints, frontend `useSubscription.isPaidPlan`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched — that string means "shared with my account" and has nothing to do with the subscription tier.
New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert of `plan_billing` rows from Stripe products by exact name match (`ResolutionFlow Starter / Pro / Enterprise`). Picks the active monthly recurring price for tiers that have one. Annual fields stay NULL by design — annual pricing is intentionally out of scope for the soft cutover ("want to be able to exit if necessary without breaching any terms").
**Rejected:**
- **Map marketing names to existing slugs (Option A from the discussion).** Smallest diff but means PricingPage cards have to translate `enterprise``team` at render time, and "Starter" can't exist as a real backend tier — it'd have to be hidden or dropped. Kicks the can.
- **Add `starter` only, keep `team` slug as cosmetic enterprise (Option C).** Mixed taxonomy across layers — slug-vs-display-name divergence guarantees confusion in 6 months. Compromise that's worse than either pure choice.
- **Annual pricing in this iteration.** User's explicit constraint: skip annual to keep exit-flexibility. Schema columns (`annual_price_cents`, `stripe_annual_price_id`) preserved as nullable for future re-enable.
- **Auto-archive the existing Enterprise `$500/mo` test-mode price.** Done manually via Stripe MCP after un-setting the product's `default_price` first. Spec says Enterprise is sales-led with no catalog price.
**Consequences:**
- `plan_billing` table is now seedable and seeded. Test-mode `plan_billing` populated for all 3 tiers via `sync_stripe_plan_ids.py`. Live mode runs the same script after manual Dashboard setup of products + prices.
- New consumers of `Subscription.plan` literal must use `("free", "pro", "starter", "enterprise")`. Three call sites already updated. Backend-wide grep is the safety net for new ones.
- `Subscription.is_paid` and `has_pro_entitlement` now include `starter` — Starter is a paid tier with a real $19.99/mo price.
- 86/86 passing across the subscription/billing/plan/invite/admin sweep after the rename.
- Test fixtures: `conftest.py` plan_limits seed updated to the new taxonomy. `_seed_plan_limits` helper in `test_plans_public.py` is now a true upsert so tests can override `max_users` even when conftest seeded the canonical value.
---
## 2026-05-07 — Standardize backend Python on 3.12
**Context:** Runtime facts had drifted from docs. The backend Dockerfiles and running dev container were already on Python 3.12, GitHub CI had just been updated to 3.12, but project docs still said Python 3.11 and Gitea CI relied on the runner's ambient Python.
**Decision:** Treat Python 3.12 as the backend standard. Pin local pyenv via `.python-version` to 3.12.13, matching the current `python:3.12-slim` container patch level. Add explicit Python 3.12 setup to Gitea CI and keep GitHub CI on Python 3.12.
**Rejected:** Moving Docker/runtime back to Python 3.11. The application was already building and running on 3.12, so reverting the runtime would add churn without a product or dependency reason.
**Consequences:** Native backend work should use `backend/venv` created from Python 3.12.13. Future docs/CI/runtime changes should preserve Python 3.12 unless a deliberate upgrade decision is recorded.
## 2026-04-30 — Add `applied_pending` non-terminal status to suggested fixes
**Context:** The verifying banner forces a synchronous verdict — worked / didn't / partial — but a lot of real MSP fixes are async. Engineer ran the script but is waiting on the client to power-cycle, AD replication, an O365 license sync. With only the existing outcomes, the engineer either leaves the banner stale (eroding the verifying signal) or guesses wrong (corrupting outcome data). User flagged the gap directly. Today's `NudgeBanner` "Still checking" button just silences the nudge — it doesn't tell the system anything.

View File

@@ -2,35 +2,47 @@
# HANDOFF.md
**Last updated:** 2026-05-06 (Phase 1 backend complete on `feat/self-serve-signup-spec`)
**Last updated:** 2026-05-08
**Active task:** Phase 1 self-serve signup backend foundation — DONE on branch. PR not yet opened.
**Active task:** PR #164 (`feat/billing-plan-taxonomy`) open in Gitea with 5 commits at head `2c9f5e9`. Closes the last code blockers for self-serve cutover. After merge, only manual ops remain (Stripe live-mode Dashboard config, Railway prod env vars, internal validation, flag flip). PR #162 and #163 merged into main this session as squash commits `f1be3ab` and `dad5e1f`.
## Where this session ended
24 commits on top of `main` (`31ca3fb`). All 26 tasks from `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-1-backend.md` complete. Full pytest run is green (1167 passed, 35 deselected). Single alembic head: `c6cbfc534fad`.
PR #164 commits (oldest → newest):
Phase 1 covered: schema additions (oauth_identities, plan_billing, sales_leads, stripe_events, plus 5 new columns across users/accounts/account_invites), Subscription complimentary status + has_pro_entitlement, the two new guards (`require_active_subscription`, `require_verified_email_after_grace`), full BillingService (start_trial / create_checkout_session / apply_subscription_event / get_billing_state), Stripe webhook handler, Google + Microsoft OAuth callbacks with oauth_identities linking, OAuth-only password guard, register-time verification email + invite email-match, bulk + soft-revoke invite routes, GET /billing/state, and the pilot complimentary backfill migration.
1. `ba36c47 feat(billing): reconcile plan taxonomy and add Stripe sync script` — migration `4ce3e594cb87` renames `plan_limits.plan='team'``'enterprise'` (defensive update of any subscriptions on the old slug; dev had zero), adds `starter` row with caps interpolated between free and pro. Code rename across schemas, `Subscription` paid-plan checks, admin endpoints, frontend `useSubscription`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched. New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert of `plan_billing` rows from Stripe products by exact name match, picks active monthly recurring price, leaves annual fields NULL by design.
2. `a628b24 chore(dev): pass STRIPE_* env to backend container` — wires `STRIPE_*` + `SELF_SERVE_ENABLED` + `INTERNAL_TESTER_EMAILS` through `docker-compose.dev.yml`. New repo-root `.env.example`.
3. `8494366 feat(billing): add INTERNAL_TESTER_EMAILS allowlist for self-serve soft cutover``Settings.is_internal_tester` + `is_self_serve_active_for`, new `get_current_user_optional` dep, `/config/public` honors allowlist for authenticated callers, `/auth/register` allows allowlisted emails without invite code. 5 regression tests in `test_config_public.py`.
4. `8649a4a docs: refresh CURRENT-STATE, ROADMAP, README, DECISIONS for self-serve cutover` — CURRENT-STATE bumped with PR #159164 entries; ROADMAP got a "Status as of 2026-05-07" preamble (historical content preserved underneath); README fixed legacy `patherly_postgres` and `UI-DESIGN-SYSTEM.md` references; DECISIONS appended two entries.
5. `2c9f5e9 fix(frontend): page-title — escapes + propagate plan taxonomy through frontend types``LandingPage.tsx` had `—` (six literal characters) inside JSX attribute strings, rendering as literal text in browser tabs. Replaced with literal em dash. PageMeta default tagline updated from "Decision Tree Platform" (stale) to "AI-Powered Troubleshooting for MSPs". Fixed TS errors that surfaced from the previous taxonomy commit not propagating through frontend types — `types/{account,admin,billing}.ts`, `admin/{AccountsPage,InviteCodesPage}.tsx`, `AccountSettingsPage.tsx`, `subscription/CheckoutButton.tsx`. tsc -b clean, lint clean.
The conftest's `test_user` fixture was modified to seed a Pro/active Subscription post-register (delete-then-insert) so the new subscription guard doesn't 402 every existing test. Two existing tests adapted because they explicitly assumed the old free-plan default: `test_subscription_limits.py` (the two free-plan tests now downgrade inline) and `test_kb_accelerator.py::TestQuota::test_get_quota` (the `kb_setup` fixture downgrades to free).
Stripe state (test mode via MCP, livemode=false): 3 active products (Starter $19.99/mo, Pro $29.99/mo, Enterprise no price); leftover Enterprise `$500/mo` test price archived (had to clear `default_price` on the product first); `plan_billing` populated for all three tiers in dev DB via `sync_stripe_plan_ids.py`.
## Resume point — DO THIS NEXT
Working tree clean (only pre-existing untracked files: `abc-feat-self-serve-signup-phase-2-design-...md`, `core.*`, `docs/architecture/`, `docs/tutorials/` — same set noted in prior handoff as "do not stage").
1. Open the PR for branch `feat/self-serve-signup-spec`. Use `gh pr create` against `main`. Suggested title: `feat: self-serve signup backend (Phase 1)`. Body should mention dark-launch posture (every new endpoint is gated by env config, not a feature flag — see Task 26 §3 in the plan).
2. Phase 2 (frontend + cutover) lives in a sibling plan: `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend.md` (assumed; verify path). It's the next logical task once Phase 1 ships.
Single alembic head: `4ce3e594cb87` after PR #164 merges (was `c6cbfc534fad`).
## Followups deferred from this session
## Resume point
- **OAuth callbacks don't call `_store_refresh_token`.** The Google/Microsoft callbacks issue a refresh JWT but never persist its hash to `refresh_tokens` (the password-login flow does via `auth.py:_store_refresh_token`). Result: refresh-token revocation/rotation lookups won't find OAuth-issued tokens. Decide before Phase 2 dark-launch whether to backfill — likely yes, by extracting `_store_refresh_token` to a shared module and calling it from `_sign_in_or_register`.
- **`stripe_enabled` was relaxed** in Task 14 from `bool(STRIPE_SECRET_KEY) and bool(STRIPE_WEBHOOK_SECRET)` to just the secret key. The webhook handler in Task 16 independently checks `STRIPE_WEBHOOK_SECRET` before calling `construct_event`, so signature verification is still safe — but if any other code reads `stripe_enabled` and assumes the webhook secret is set, that's a latent bug. Audit before Phase 2 cutover.
- **`backend/app/core/stripe_handlers.py`** is a stub module that's no longer referenced after Task 16. Safe to delete in a follow-up; left in place to keep Phase 1 diff focused.
- **Pilot backfill migration `c6cbfc534fad` has not been applied to prod yet.** It runs once at deploy time and is forward-only.
1. Verify PR #164 CI green:
`curl -fsSL https://gitea.resolutionflow.com/api/v1/repos/chihlasm/resolutionflow/commits/2c9f5e9/status | python -m json.tool`
2. Squash-merge PR #164.
3. **Phase O manual ops** (after merge):
- Stripe Dashboard live-mode: 3 Products, monthly Prices for Starter ($19.99) + Pro ($29.99), no Prices on Enterprise (sales-led), Customer Portal with plan-switching disabled, webhook at `https://api.resolutionflow.com/api/v1/webhooks/stripe` with 5 events. Save live signing secret.
- Railway prod env: `STRIPE_SECRET_KEY=sk_live_...`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PUBLISHABLE_KEY` + `VITE_STRIPE_PUBLISHABLE_KEY` (frontend redeploy required — Vite bake-at-build, Lesson 60), `OAUTH_REDIRECT_BASE=https://resolutionflow.com`, `SELF_SERVE_ENABLED=false` (still false at this point), `INTERNAL_TESTER_EMAILS=<allowlist>`, prod Google + Microsoft OAuth credentials.
- Run sync against prod backend: `railway run python -m scripts.sync_stripe_plan_ids`. Verify `plan_billing` rows have `sk_live_*` price IDs.
4. Internal validation (Phase O Task 46): 9 scenarios with internal testers whose emails match `INTERNAL_TESTER_EMAILS`.
5. Flag flip (Task 47): email pilots, set `SELF_SERVE_ENABLED=true` + `VITE_SELF_SERVE_ENABLED=true` (frontend redeploy). PostHog signup-funnel dashboard + Sentry alert at >1/hour Stripe webhook errors.
## Environment notes (carry-forward)
## Open issues from this session (non-code, user-side)
- Code-server LXC has bun + docker but no native python/node/npm. Use `docker exec resolutionflow_{backend,frontend} ...` for build/test commands.
- Pytest WORKDIR is `/app` — test paths in pytest commands are `tests/<file>`, NOT `backend/tests/<file>`.
- Backend pytest cmd: `docker exec resolutionflow_backend pytest tests/<path> -v --override-ini="addopts="`. The full run takes ~25 min.
- Alembic via `docker exec -w /app resolutionflow_backend alembic ...`. Never pass `--rev-id`.
- No `gh` CLI on this LXC — use the Gitea API (`$GITEA_TOKEN` in `.claude/settings.local.json`) for PR/issue work, or run `gh` from a host that has it.
- Headless Chromium (`/qa`, `/browse`) needs `CONTAINER=1` in the env launching the browse server (LXC namespace constraint).
- **Apex DNS missing.** `resolutionflow.com` (apex) returns no A/CNAME at the authoritative DNS (Namecheap per SOA `dns1.registrar-servers.com.`). When `www` was reconfigured in Railway, the apex record got dropped from the zone. `www` works (cert provisioned 2026-05-08 01:40 UTC, valid Let's Encrypt SAN). Symptom: apex unreachable from user's machine; Stripe verifier "URL couldn't be reached." User to re-add apex record at Namecheap (ALIAS Record host=`@` value=`c9g7uku8.up.railway.app`) or re-add the apex as a Railway custom domain and follow Railway's DNS instructions. The Railway path is more durable.
- **Edge HSTS sticky state on user's machine.** Browser remembers the earlier broken-cert visit. Fix: `edge://net-internals/#hsts` (delete `resolutionflow.com` and `www.resolutionflow.com`) + `#dns` clear host cache + `#sockets` flush. Cert IS valid on the wire (proven by `curl --resolve` returning 200 OK from the user's box).
## Carry-forward
- Annual pricing intentionally NOT implemented — user wants exit flexibility ("want to be able to exit if necessary without breaching any terms"). Schema columns (`annual_price_cents`, `stripe_annual_price_id`) preserved as nullable for future re-enable. `sync_stripe_plan_ids.py` leaves annual fields NULL.
- `INTERNAL_TESTER_EMAILS` parsed comma-separated → normalized lowercase list. Anonymous callers always see the global flag — allowlist never leaks via unauthenticated request content (regression test enforces).
- Office-hours design doc from this session at `~/.gstack/projects/chihlasm-resolutionflow/abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`. Captures the "documentation builder" thesis (cut branching Flows from pilot UI, focus on Day 1 onboarding checklist + 3 deep-capture procedures + Hudu/IT Glue/CW output). Pre-build assignment: 3 cold calls with external Directors of Onboarding before scoping the build. NOT yet adopted as roadmap — gated on the validation calls.
- Frontend lint shows 3 warnings in `coverage/` (auto-generated). Untouched.
- Backend env: `SALES_LEAD_RECIPIENT_EMAIL`. Frontend env additions for cutover: `VITE_SELF_SERVE_ENABLED`, `VITE_GOOGLE_CLIENT_ID`, `VITE_MS_CLIENT_ID`, `VITE_OAUTH_REDIRECT_BASE`, `VITE_CALENDLY_URL`, `VITE_STRIPE_PUBLISHABLE_KEY`.

View File

@@ -26,7 +26,7 @@ Go-to-Market Validation (pre-PMF). Backend feature-complete (55+ endpoints, 100+
## Tech stack
- **Backend:** Python 3.11 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
- **Backend:** Python 3.12 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
- **Frontend:** React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config in `index.css`), Zustand (immer + zundo), React Router v7, Axios (token-refresh interceptor), Lucide.
- **DB:** PostgreSQL 16 (RLS enabled Phase 4, pgvector).

View File

@@ -12,6 +12,67 @@
---
## 2026-05-08 03:30 UTC — Claude — PR #164 self-serve cutover code blockers, doc refresh, page-title bug, DNS triage
**Accomplished:**
- Merged PR #162 (self-serve Phase 2 frontend) and PR #163 (seed users email-verified) into main via Gitea API squash merge. Created branch `feat/billing-plan-taxonomy` off the new main; pushed 5 commits closing the last code blockers for Phase O cutover. PR #164 opened at gitea pulls/164.
- Plan taxonomy reconciliation. Discovered the marketing surface (PricingPage, Stripe products) was wired for `Starter / Pro / Enterprise` while backend was on `free / pro / team`; `BillingPlan` schema's `Literal["pro","starter","team","enterprise"]` could accept FK-violating values; `plan_billing` was unseeded. Migration `4ce3e594cb87` renames `plan_limits.plan='team'``'enterprise'` (defensive update of any subscriptions on the old slug; dev had zero), adds `starter` row with caps interpolated between free and pro (`max_trees=10`, `sessions=75`, `users=1`, `ai=15/mo`, no KB Accelerator, no custom branding, no priority support). Code rename across schemas (`invite_code`, `billing`, `admin`, `subscription`), `Subscription` paid-plan/`has_pro_entitlement` checks, `admin_dashboard.py`, `admin.py`, frontend `useSubscription.isPaidPlan`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain (means "shared with my account") and intentionally untouched. 86/86 passing across subscription/billing/plan/invite/admin sweep after the rename. Conftest plan_limits seed + `_seed_plan_limits` helper made a true upsert.
- New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert from Stripe products by exact name match (`ResolutionFlow Starter / Pro / Enterprise`), picks active monthly recurring price, leaves annual fields NULL by design. Works against test or live keys via `STRIPE_SECRET_KEY`. Run against test mode populated `plan_billing` for all 3 tiers in dev DB. Annual pricing intentionally skipped per user's exit-flexibility constraint.
- Stripe MCP work (test mode, `livemode=false`): archived leftover Enterprise `$500/mo` test price (had to clear the product's `default_price` first — Stripe blocks archive otherwise). Verified test-mode product set: Starter $19.99/mo, Pro $29.99/mo, Enterprise no price (sales-led).
- `INTERNAL_TESTER_EMAILS` allowlist. Phase O Task 46 needed it as a code blocker (flagged in prior SESSION_LOG as "backend support is NOT yet built"). `Settings.is_internal_tester` (case-insensitive membership) + `is_self_serve_active_for(email)` (returns global flag OR allowlist hit) centralize the check. New `get_current_user_optional` dep — best-effort auth that returns `None` instead of 401, used by `/config/public` so the same endpoint serves anonymous and authed. `/config/public` returns `self_serve_enabled=true` for authenticated allowlist members; `/auth/register` allows allowlisted emails without invite code. 5 regression tests including "anonymous callers always see the global flag" (prevents leak via unauthenticated request content).
- Stripe env passthrough: `docker-compose.dev.yml` now wires `STRIPE_*` + `SELF_SERVE_ENABLED` + `INTERNAL_TESTER_EMAILS` into the backend container. New repo-root `.env.example`. `backend/.env.example` updated with the self-serve cutover vars.
- Page-title bug fix on `LandingPage.tsx`. Two JSX attribute strings (`title="..."`, `description="..."`) had `—` (six literal characters) — JSX attribute strings don't process JS escape sequences, so the browser tab and OG description rendered the literal text instead of an em dash. Replaced with the literal em dash character. Verified by grep — every other `\u...` in the codebase is inside a real JS string (`'...'` literal or `{...}` JSX expression) where escapes resolve at compile time. PageMeta default tagline updated from stale "Decision Tree Platform" to "AI-Powered Troubleshooting for MSPs" (matches index.html and brand positioning).
- Frontend taxonomy followups (caught by tsc -b after rebuild). The earlier taxonomy commit didn't propagate through frontend types: `types/account.ts`, `types/admin.ts`, `types/billing.ts`, `admin/AccountsPage.tsx` (state type, select onChange cast, `<option value="team">` rendered UI), `admin/InviteCodesPage.tsx` (PLAN_OPTIONS array, state type, onChange cast), `AccountSettingsPage.tsx` (`plan !== 'team'` check + CheckoutButton prop), `subscription/CheckoutButton.tsx` (prop type + planLabels). All updated to `'free' | 'pro' | 'starter' | 'enterprise'`. tsc clean. Lint clean (3 warnings only in auto-generated `coverage/`).
- Doc refresh commit (`docs: refresh CURRENT-STATE, ROADMAP, README, DECISIONS for self-serve cutover`). CURRENT-STATE bumped to 2026-05-07; added entries for PR #159164; refreshed What's In Progress / What's Next around Phase O. ROADMAP got a "Status as of 2026-05-07" preamble (months-stale historical content kept underneath as record); In Progress and What's Next sections updated. README fixed legacy `patherly_postgres` Docker command, project-tree path, `UI-DESIGN-SYSTEM.md` reference; added `AGENTS.md`, `PROJECT_CONTEXT.md`, `PRODUCT.md` to docs table. DECISIONS appended two entries (taxonomy reconciliation, allowlist).
- Office-hours session ran via `/office-hours` skill earlier in this session. Design doc saved at `~/.gstack/projects/chihlasm-resolutionflow/abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`. Captured the "documentation builder" thesis — cut branching Flows from pilot UI, focus product around FlowPilot + Day 1 onboarding checklist as navigational frame + 3 deep-capture procedures (M365 tenant build, Windows server build, credential vault) + Hudu/IT Glue/ConnectWise output. Founder is a Director-of-Onboarding at his own MSP (Andrea Henry); pre-build assignment is 3 cold calls with external Directors of Onboarding before scoping. NOT yet adopted as roadmap.
- DNS / cert triage: `www.resolutionflow.com` was unreachable (Railway "train hasn't arrived" page) — user added it as a custom domain in Railway, cert provisioned at 2026-05-08 01:40 UTC, `www` now serves 200 with valid Let's Encrypt SAN. Apex `resolutionflow.com` separately discovered to have NO A/CNAME at authoritative DNS (Namecheap per SOA `dns1.registrar-servers.com.`). When user reconfigured `www`, the apex record dropped from the zone. From Railway-edge IP both names work fine when DNS is forced (proven by `curl --resolve` returning 200 OK from user's box) — so the apex cert is also valid; the failure mode is purely DNS-level absence. User asked for HSTS clearance steps in Edge — provided `edge://net-internals/#hsts`, `#dns`, `#sockets` walkthrough plus Linux DNS flush options.
**Left for next session:**
- Verify PR #164 CI green, then squash-merge.
- Phase O manual ops sequence (Stripe Dashboard live-mode setup, Railway prod env vars including `INTERNAL_TESTER_EMAILS`, run `sync_stripe_plan_ids.py` against prod, internal validation Task 46, flag flip Task 47, PostHog dashboards, Sentry alert).
- User-side: re-add apex DNS record at Namecheap (ALIAS `@``c9g7uku8.up.railway.app`, or re-add apex in Railway), clear Edge HSTS state.
**Files touched (all on `feat/billing-plan-taxonomy`, all pushed):** `backend/alembic/versions/4ce3e594cb87_add_starter_rename_team_to_enterprise.py` (new), `backend/scripts/sync_stripe_plan_ids.py` (new), `backend/app/{schemas/{billing,invite_code,admin,subscription}.py, models/subscription.py, api/{deps.py, endpoints/{auth.py, admin.py, admin_dashboard.py, config.py}}, core/config.py}`, `frontend/src/{components/{common/PageMeta.tsx, subscription/CheckoutButton.tsx}, hooks/useSubscription.ts, pages/{LandingPage.tsx, AccountSettingsPage.tsx, admin/{AccountsPage.tsx, InviteCodesPage.tsx}}, types/{account.ts, admin.ts, billing.ts}}`, `backend/tests/{conftest.py, test_admin_plan_limits.py, test_invite_plan.py, test_plans_public.py, test_config_public.py}`, `docker-compose.dev.yml`, `.env.example` (new), `backend/.env.example`, `CURRENT-STATE.md`, `03-DEVELOPMENT-ROADMAP.md`, `README.md`, `.ai/{DECISIONS.md, HANDOFF.md, CURRENT_TASK.md, SESSION_LOG.md}`.
---
## 2026-05-07 11:45 EDT — Codex — Push PR #162 CI runner setup fixes
- Inspected Gitea PR #162 via public API. PR head was `380fcf7` and all CI jobs failed quickly; pushed local commits through `4a37a47`, including Python 3.12 setup for Gitea backend/e2e jobs.
- New run on `4a37a47` showed frontend still failed quickly while backend/e2e remained pending. Root cause likely same class of runner drift: Gitea frontend/e2e jobs used `npm` without setting up Node.
- Added explicit `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs. This keeps CI from relying on runner ambient Node/npm.
- Files touched: `.gitea/workflows/ci.yml`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-07 11:30 EDT — Codex — Standardize backend Python on 3.12
- Standardized repo declarations around Python 3.12: added `.python-version` pinned to 3.12.13, updated stale Python 3.11 docs, and added explicit Python 3.12 setup steps to Gitea CI. GitHub CI was already updated to Python 3.12 by the user.
- Installed pyenv Python 3.12.13 and created `backend/venv` from that interpreter. Installed `backend/requirements-dev.txt` into the venv.
- Verified native `python --version` and venv `python --version` both report 3.12.13. Verified native `pytest 8.4.2` and `alembic 1.18.3` with explicit safe test env vars; plain pytest import still depends on local `.env` values being valid.
- Rebuilt and restarted the dev backend container with `docker compose -f docker-compose.dev.yml build backend` and `up -d backend`; confirmed `docker exec resolutionflow_backend python --version` reports 3.12.13.
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `README.md`, `DEV-ENV.md`, `.ai/PROJECT_CONTEXT.md`, `.ai/DECISIONS.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-07 11:14 EDT — Codex — Recheck native Python availability
- Re-ran the startup ritual and checked the host Python state after the user reported fixing the missing native Python issue.
- Verified `python` and `python3` resolve to `/config/.pyenv/shims/*` and run Python 3.12.10. `pip` and `pip3` are available as pip 25.0.1 under the same pyenv install.
- Confirmed there is no native `python3.11`, pyenv currently lists only `3.12.10`, no repo virtualenv exists under `backend/venv`, `backend/.venv`, or root `.venv`, and `python -m pytest --version` from `backend/` fails with `No module named pytest`.
- Conclusion: native Python is present, but it is not yet a ready backend dev/test environment for ResolutionFlow. Docker remains the reliable path for pytest/alembic until a Python 3.11 virtualenv with `backend/requirements*.txt` is installed.
- Files touched: `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-06 — Claude — Self-serve signup Phase 2 (frontend + cutover code) shipped on `feat/self-serve-signup-phase-2`
- Executed Tasks 2744 of `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md` via `superpowers:subagent-driven-development`. 18 commits on `feat/self-serve-signup-phase-2` (off `main` `f918b76`); HEAD `c75ce0c`. Each task: dispatched implementer subagent with full task text + curated context, then spec-compliance + code-quality review subagents; review issues either fixed in-flight via `git commit --amend` or noted as deferred scope.
- Backend (Phase I, Tasks 2731): `BillingService.open_customer_portal` + `GET /billing/portal-session`; `PATCH /users/me/onboarding-step` + dismiss-rest sibling; public `POST /sales-leads` (5/hr/IP); `/admin/plan-limits` GET/PUT round-trips `plan_billing` in one transaction with NOT-NULL guards on `display_name|is_public|is_archived|sort_order`; `BillingService.invalidate_billing_cache` no-op stub; `GET /config/public` (`{self_serve_enabled, oauth_providers}`); `auth/register` invite-code gate now `REQUIRE_INVITE_CODE and not SELF_SERVE_ENABLED and not invite_code`. Also (T36): `GET /accounts/invites/{code}/lookup` (public, joinedload account+inviter); OAuth callback honors `account_invite_code+invited_email`, rejects existing-email user with `email_already_registered_use_login`. Also (T42, T44): `GET /plans/public`; `POST /beta-signup` returns 307 to `${FRONTEND_URL}/register?from=beta`. `OnboardingStatus` extended with `email_verified`+`shop_setup_done`; `UserResponse` exposes `onboarding_step_completed`+`onboarding_dismissed`.
- Frontend (Phases JN, Tasks 3244): `useBillingStore` Zustand store + `useBillingPoll` mounted in `AppLayout`; `useFeature` / `useFeatureLimit` (60s module cache, lazy `/usage/{field}` fetch with silent fallback — endpoint deferred) / `useTrialBanner` (fractional-day boundary so 24h = warning); `FeatureGate` / `UpgradePrompt` (inline `FEATURE_CATALOG`) / `EmailVerificationGate` (mounted in AppLayout around `<ViewTransitionOutlet />`). `RegisterPage` redesign with OAuth buttons + invite-code conditional; `OAuthCallbackPage` with CSRF state validation + UTF-8-safe base64url state encoding (factored into `lib/oauthState.ts`); `useAppConfig` hook. `AcceptInvitePage` at `/accept-invite` with locked email; `EmailVerificationBanner` refactored to design-system tokens; `EmailVerificationWall` polished; `VerifyEmailPage` at `/verify-email` with single-fire ref guard; `WelcomeRouter` + `WelcomeStep1/2/3` at `/welcome*`; `TrialPill` in topbar (8 stages); `NextStepCard` + `SetupChecklist` (replace orphaned `OnboardingChecklist`); `PricingPage` at `/pricing`; `ContactSalesPage` at `/contact-sales`; `LandingPage` got "See pricing" CTA + replaced beta-signup form with `<Link>`.
- Final cross-cutting review caught one real bug — relative `/beta-signup` 307 target landing on API origin instead of frontend — fixed via amend (HEAD `c75ce0c`).
- Tests: ~165+ new tests across backend pytest + frontend vitest. Sweep at end-of-branch all-green; tsc -b clean.
- Phase O (Tasks 4547) is explicit manual operations: Stripe live-mode setup, internal validation via `INTERNAL_TESTER_EMAILS` per-email allowlist (backend support for that allowlist is NOT yet built), feature-flag flip + week-1 monitoring. Surfaced as the resume point in HANDOFF.md.
- Working tree was dirty before this session (`.ai/HANDOFF.md`, `.env.example`s, `core.*` core dumps, `docs/architecture/`, `docs/tutorials/`); intentionally not staged into Phase 2 commits. Files touched: see `git log --oneline f918b76..HEAD` on `feat/self-serve-signup-phase-2`.
---
## 2026-05-02 ~01:00 UTC — Claude — In-product User Guides Diátaxis rewrite shipped (PR #159)
- Audited the in-product `/guides` collection against live UI via `/browse` (engineer + owner test users). Existing 15 guides predated the FlowPilot pivot — every "click X in the sidebar" reference was wrong (Dashboard → Home, All Flows → Flows, Sessions → History, Exports gone, etc.). Three guides described surfaces that no longer exist: Maintenance Flows, AI Assistant page, Flow Assist Sparkles button. Findings written to `/tmp/guides-audit.md`.
@@ -301,3 +362,13 @@
- Files touched: `.ai/*.md` (created), `CLAUDE.md` (rewritten), `AGENTS.md` (created), `SESSION-HANDOFF.md` (deleted).
- Follow-up (same day): Codex review pass flagged stale SaaS-role claim and incomplete file-listings carried over from the pre-migration CLAUDE.md. Verified against `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`, `backend/app/api/deps.py`, `backend/app/api/router.py`, and `backend/app/services/psa/`. Corrected PROJECT_CONTEXT.md role hierarchy (`super_admin > owner > engineer > viewer`, not `team_admin`), added `require_account_owner` / `require_team_admin` to deps list, replaced stale endpoint comment with a summary pointing at `api/router.py`, added `exceptions.py` + `ticket_context.py` to the PSA file list. Also replaced seed-example content in `CURRENT_TASK.md` and `TODO.md` with clearer empty-state sentinels.
- Branch cleanup (same day): committed pending test-isolation work as `b14a16a chore(tests): gate RLS tests behind RUN_RLS_TESTS flag`, new Phase 9 review doc as `b3506b5 docs(pilot): phase 9 review issues`, and `.remember/` gitignore entry as `b3be1e0 chore: ignore .remember/ skill runtime state`. Deleted `docs/landing-handoff/` (prepared for external design work, not meant to live in the repo). Working tree clean; 3 cleanup commits unpushed.
## 2026-05-07 UTC — Codex — Resolve PR #162 CI failures
- Investigated Gitea PR #162 failing checks for `feat/self-serve-signup-phase-2`. Public status metadata was available, but job logs required Gitea login and no token was present.
- Standardized backend development/CI Python on 3.12.13 to match the Docker image: added `.python-version`, updated Gitea CI Python setup, rebuilt the local backend virtualenv, and verified native `pytest` / `alembic` command availability with explicit local env.
- Added explicit Node 20 setup to Gitea frontend and e2e jobs so CI no longer depends on the runner's ambient Node installation.
- Reproduced the remaining frontend failure locally. Lint failed on Phase 2 React code because the current eslint stack flags exported pure helpers, render-time `Date.now()`, and effect-driven state synchronization.
- Patched the affected frontend surfaces narrowly: dashboard helper exports, app-config cache handling, feature-limit cache/fetch state, trial-banner time capture, invite/OAuth route error state, pricing loading state, and OAuth authorize URL helper export.
- Verified sequential frontend CI locally in Docker: `npm run lint` passed, `npm run test:coverage` passed (`198` tests), and `npm run build` passed with only Vite chunk-size warnings.
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `.ai/*`, `README.md`, `DEV-ENV.md`, and the frontend lint-fix files under `frontend/src/components/dashboard`, `frontend/src/hooks`, and `frontend/src/pages`.

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
REPO_ROOT=/opt/docker/code-server/workspace/resolutionflow
POSTGRES_PORT=5433
SECRET_KEY=
ANTHROPIC_API_KEY=
GOOGLE_AI_API_KEY=
STRIPE_SECRET_KEY=sk_test_
STRIPE_PUBLISHABLE_KEY=pk_test_
STRIPE_WEBHOOK_SECRET=whsec_
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_
INTERNAL_TESTER_EMAILS=internaltest@resolutionflow.com

View File

@@ -46,6 +46,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Cache pip
uses: actions/cache@v3
with:
@@ -105,6 +110,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Cache npm
uses: actions/cache@v3
with:
@@ -171,6 +181,16 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Cache pip
uses: actions/cache@v3
with:

View File

@@ -37,10 +37,10 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.11
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.12"
cache: pip
cache-dependency-path: |
backend/requirements.txt
@@ -143,10 +143,10 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.11
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.12"
cache: pip
cache-dependency-path: |
backend/requirements.txt

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12.13

View File

@@ -1,11 +1,25 @@
# Development Roadmap
> **Last Updated:** March 18, 2026
> **Product:** ResolutionFlow (repo: patherly)
> **Last Updated:** May 7, 2026
> **Product:** ResolutionFlow (repo path: `resolutionflow/`; `patherly` is the legacy internal name)
> **Target Market:** MSP companies — IT service providers managing infrastructure and support for multiple clients
---
## Status as of 2026-05-07
The historical phase content below (Phase 1 through Phase 5) is preserved as a factual record. **This section is the live status overlay — read it first.**
**Where we are:** Pre-PMF, Go-to-Market Validation. Backend feature-complete (50+ endpoints, 100+ tests). FlowPilot session UX is the daily-driver surface and recently went through PR #155 (escalation wedge), #156 (`applied_pending` non-terminal status), #158 (impeccable pass + tasklane keyboard flow), #159 (Diátaxis User Guides), #160 (sidebar IA + account redesign).
**Currently in flight:** Self-serve signup cutover. Phase 1 backend (#161) and Phase 2 frontend (#162) merged. PR #164 (open) closes the last code blockers — plan taxonomy reconciliation (`team``enterprise`, add `starter`) and `INTERNAL_TESTER_EMAILS` allowlist for the soft cutover. After merge, remaining work is **manual operations only**: Stripe Dashboard live-mode setup, Railway prod env vars, internal validation pass, public flag flip. See `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md` Phase O for the checklist.
**Product thesis being tested:** "We're not a documentation app. We are the documentation builders." Captured in `~/.gstack/projects/chihlasm-resolutionflow/abc-feat-self-serve-signup-phase-2-design-20260507-112020.md` (office-hours design doc). Pre-build assignment: 3 calls with external Directors of Onboarding (cold, no friendly contacts) to validate the framing before adopting it as the public positioning.
**What's not yet decided:** Whether to formally cut branching Flows from the pilot UI surface in favor of a Project (linear procedure) + FlowPilot + Documentation-Builder positioning. Discussed in /office-hours but no implementation work scheduled — gated on the 3 external validation calls.
---
## Completed Work
### Phase 1: MVP
@@ -72,13 +86,26 @@
| Task | Status | Notes |
|------|--------|-------|
| ConnectWise PSA Integration (Advanced) | In Progress | Core done — ticket linking, note posting, member mapping. Remaining: callback webhooks, deeper ticket context in sessions |
| PR #114 Merge | In Progress | Empty states, onboarding, PDF exports, branding, supporting data — ready for review |
| Self-serve signup cutover (Phase O) | In Progress | PR #164 merge → Stripe live-mode Dashboard setup → Railway prod env vars → internal validation → public flag flip. Code blockers cleared by #164 (taxonomy + `INTERNAL_TESTER_EMAILS` allowlist). |
| External validation of documentation-builder thesis | Not started | 3 calls with external Directors of Onboarding (cold). Decision gate before scoping a "Day 1 onboarding checklist" build. |
| ConnectWise PSA Integration (Advanced) | Deferred | Core complete — ticket linking, note posting, member mapping, ticket context retrieval. Callback webhooks deferred until pilot signal demands them. |
---
## What's Next
### Phase O Cutover (Weeks 0-1)
| Step | Status |
|---|---|
| Merge PR #164 (taxonomy reconciliation + allowlist) | Open, CI green |
| Stripe Dashboard live-mode setup (Products + Prices for Starter/Pro, no Prices on Enterprise, Customer Portal config, webhook endpoint with 5 events) | Manual op |
| Railway prod env vars (`sk_live_*`, `whsec_*`, `INTERNAL_TESTER_EMAILS`, prod Google + Microsoft OAuth credentials, `OAUTH_REDIRECT_BASE`, `STRIPE_PUBLISHABLE_KEY`, `VITE_STRIPE_PUBLISHABLE_KEY` for frontend redeploy) | Manual op |
| Run `python -m scripts.sync_stripe_plan_ids` against prod backend; verify `plan_billing` has `sk_live_*` price IDs | Manual op |
| Internal validation pass (9 scenarios from Phase O Task 46) | Manual op |
| Email pilots about complimentary status, flip `SELF_SERVE_ENABLED=true` (frontend redeploy required for `VITE_SELF_SERVE_ENABLED`) | Manual op |
| PostHog signup-funnel dashboard + Sentry alert at >1/hour Stripe webhook errors | Manual op |
### Near-Term Priorities (from Stack Priorities Plan)
| Feature | Status | Description |
@@ -86,7 +113,7 @@
| Coverage gates in CI | ✅ Complete | Backend enforced at 80%, frontend coverage reporting enabled |
| Security headers | ✅ Complete | HSTS, CSP (report-only), X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy |
| Web Vitals / performance budgets | ✅ Complete | LCP, INP, CLS, FCP, TTFB reported to PostHog via web-vitals |
| Search and recall improvements | ⬜ Not started | Search sessions by flow, tag, client, ticket context |
| Search and recall improvements | ✅ Complete | Structured filters + FTS + Voyage AI semantic search shipped (see CURRENT-STATE.md "Search & Recall" section) |
### 3A: Quick Wins & UX (Priority: Medium)

View File

@@ -2,16 +2,30 @@
> **Purpose:** Quick-reference file showing exactly where the project stands.
> **For Claude Code:** Read this first to understand what's done and what's next.
> **Last Updated:** May 1, 2026
> **Last Updated:** May 7, 2026
---
## Active Phase: Go-to-Market Validation (Pre-PMF)
## Active Phase: Go-to-Market Validation (Pre-PMF) — Self-serve cutover (Phase O) in flight
Self-serve signup backend (Phase 1) and frontend (Phase 2) are merged. Cutover (Phase O) is gated on manual ops: live-mode Stripe Dashboard config, Railway prod env vars, internal validation pass against prod test mode, then the public flag flip. Plan: `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md`.
---
## Recently shipped (post-0.1.0.0)
- **2026-05-07 — PR #164 (open)** Plan taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist. Marketing surface (PricingPage, Stripe products) used `Starter / Pro / Enterprise` while backend was on `free / pro / team`, leaving `plan_billing` unseeded and `BillingPlan` schema accepting a literal that violated the FK. Migration `4ce3e594cb87`: rename `team``enterprise` in `plan_limits`, add `starter` row (caps interpolated between free and pro: `max_trees=10`, `sessions=75`, `ai=15/mo`), defensive update of any subscriptions on the `team` slug. Code rename across schemas, `Subscription` paid-plan checks, admin endpoints, and frontend `useSubscription`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched. New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert of `plan_billing` rows from Stripe products by exact name match, picks active monthly recurring price, leaves annual fields NULL by design. Test-mode `plan_billing` populated for all 3 tiers in dev. Phase O Task 46 allowlist: `INTERNAL_TESTER_EMAILS` env var (comma-separated) bypasses `SELF_SERVE_ENABLED=false` for specific authenticated users — `Settings.is_self_serve_active_for(email)` centralizes the check; `/config/public` returns `self_serve_enabled=true` for allowlisted authenticated callers; `/auth/register` allows allowlisted emails to register without invite code. New `get_current_user_optional` dep for endpoints that work both anonymous and authed.
- **2026-05-06 — PR #163** Seed test users marked email-verified. Fixed seeded users showing the email verification banner in dev/test, blocking flows that gate on `email_verified=True`. Squash-merged into main as `dad5e1f`.
- **2026-05-06 — PR #162** Self-serve signup Phase 2 (frontend cutover). 18 commits across Tasks 2744 of the Phase 2 plan: backend remainders + frontend billing foundation + auth surfaces (OAuth + accept-invite + verify-email) + welcome wizard + dashboard redesign (TrialPill, NextStepCard, unified checklist) + public surfaces (`/pricing`, `/contact-sales`) + beta-signup deprecation. Single alembic head `c6cbfc534fad` (no new migrations in Phase 2). Squash-merged as `f1be3ab`.
- **2026-05-?? — PR #161** Self-serve signup backend (Phase 1). `plan_billing` sibling table for Stripe + catalog metadata, `sales_leads` and `stripe_events` tables, `complimentary` status with `has_pro_entitlement`, `BillingService.start_trial` wired into `/auth/register`, `/billing/checkout-session`, Stripe webhook handler with idempotency via `stripe_events`, Google + Microsoft OAuth callbacks with `oauth_identities` linking, `require_verified_email_after_grace` + `require_active_subscription` guards, bulk-create + soft-revoke invite endpoints, account-invite email-match enforcement, pilot complimentary backfill, `accounts.team_size_bucket` + `primary_psa` for wizard. Squash-merged as `f918b76`.
- **2026-05-02 — PR #159** In-product User Guides rewrite to Diátaxis how-tos. Replaced 15 feature-dump guides with 43 problem-oriented how-tos grouped under 10 categories. Dropped Maintenance Flows / AI Assistant / Flow Assist Sparkles guides (UI no longer exists). Renamed Step Library → Solutions Library. Authored 14 net-new how-tos for FlowPilot-era surfaces (tasklane keyboard flow, what-we-know, resolve, escalate, record-fix-outcome, post-docs-to-ticket, share-update, pause-and-leave, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate, etc.). Schema additions: `category`, optional `relatedSlugs`. Browser-verified against engineer + owner login.
- **2026-05-?? — PR #160** Post-PR-159 UI cleanup — sidebar IA + account redesign. Squash-merged as `a8b22cf`.
- **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Heuristic score 24/40 → 33/40 across five sub-passes (distill, quieter, layout, typeset, polish). Removed duplicate "Suggested checks" chip strip → TaskLane is the single source of truth; added inline `Next steps · N pending` cue on the latest action-bearing AI bubble; consolidated session header to Resolve + Escalate + ⋯ kebab; centered messages column to match composer; dropped all banned decorations (side stripes, gradient surfaces, backdrop blur, accent borderTop) for a single decoration channel per surface; unified 14 text sizes into a 5-step scale. TaskLane keyboard flow: Enter submits + auto-advances, Shift+Enter newline, Esc cancel, focus jumps to Send after the last task. Banner ↔ script-panel are now linked (collapse hides both, any outcome closes both). WhatWeKnow section is collapsible with `sessionStorage` memory + auto-collapse-at-5-facts. Side fix: ParameterizationPreview no longer over-highlights short parameter values (word-boundary check). Two backlog entries logged in `.ai/TODO.md`: ConcludeSessionModal multi-select and `bg-card-hover` Tailwind drift in CommandPalette.
- **2026-05-01 — PR #156** Suggested-fix "Awaiting verification" outcome. Engineers can now park a fix in `applied_pending` (waiting on client power-cycle, AD replication, license sync, etc.) instead of forcing a synchronous worked/didn't/partial verdict. PendingBanner with worked / didn't / update reason / dismiss; nudge "Still checking" records pending with a reason; page-level Resolve auto-patches pending → success before the resolution flow opens; page-level Escalate intercepts pending. Migration `c0f3a4b7e91d` (`pending_reason` column + status CHECK constraint).
- **2026-04-30 — PR #155** Escalation Mode wedge. Magic-moment handoff-context screen for senior pickup, live SSE escalation arrivals, post-claim time-to-first-action metric (`GET /analytics/flowpilot/escalations`), atomic role-gated claim with conflict resolution, queue self-exclusion, chat ownership extended to claimed sessions. The wedge for the first paying-customer push.
@@ -215,17 +229,30 @@
## What's In Progress
- **GTM Validation:** Shadow & Ship — founder uses product for 2 weeks, then hands logins to 5 colleagues
- **Solutions Library spec:** Written at `docs/plans/2026-03-23-solutions-library-design.md`, implementation deferred to post-pilot
- **Self-serve cutover (Phase O):** PR #164 (open) closes the last code blockers — taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist. After merge, remaining work is purely manual ops: live-mode Stripe Dashboard config, Railway prod env vars, internal validation pass with Andrea Henry + 2-3 external Directors of Onboarding, then `SELF_SERVE_ENABLED=true` flip with frontend redeploy.
- **Stripe live-mode setup:** Test-mode is fully wired (3 products, monthly prices for Starter/Pro, Enterprise sales-led, `plan_billing` seeded via `sync_stripe_plan_ids.py`). Live mode requires manual Dashboard config — same script handles seeding live IDs.
- **GTM Validation:** Shadow & Ship — founder uses product for real MSP tickets daily, then hands logins to 5 colleagues.
- **Solutions Library spec:** Written at `docs/plans/2026-03-23-solutions-library-design.md`, implementation deferred to post-pilot.
---
## What's Next (Priority Order)
### Phase O Cutover (Weeks 0-1)
- Merge PR #164
- Stripe Dashboard live-mode setup (Products + Prices for Starter/Pro, no Prices on Enterprise, Customer Portal config, webhook endpoint with 5 events)
- Railway prod env vars (`sk_live_*`, `whsec_*`, `INTERNAL_TESTER_EMAILS`, prod Google + Microsoft OAuth credentials, `OAUTH_REDIRECT_BASE`)
- Run `sync_stripe_plan_ids.py` against prod backend; verify `plan_billing` has `sk_live_*` price IDs
- Internal validation pass (9 scenarios from Phase O Task 46 plan)
- Email pilots about complimentary status, flip `SELF_SERVE_ENABLED=true` (frontend redeploy required for `VITE_SELF_SERVE_ENABLED`)
- PostHog dashboards + Sentry alert at >1/hour Stripe webhook errors
### Pilot Phase (Weeks 1-2)
- Founder dogfooding: use ResolutionFlow for real MSP tickets daily
- Collect feedback on copilot-first experience
- 3 calls with external Directors of Onboarding to validate the documentation-builder thesis (cold pitch, no friendly contacts)
- Collect feedback on copilot-first experience and self-serve onboarding flow
- Fix issues discovered during real usage
### Post-Pilot (Weeks 3-4)

View File

@@ -108,7 +108,7 @@ Run these in order. Stop at the first failure and investigate.
# Ubuntu / Debian
sudo apt update && sudo apt install -y \
git curl build-essential \
python3.11 python3.11-venv python3-pip \
python3.12 python3.12-venv python3-pip \
postgresql-client # not the server — only if running Postgres natively
# Node 20 via nvm (survives container rebuilds if stored in a volume)
@@ -236,7 +236,7 @@ REPO_ROOT=/absolute/path/to/resolutionflow
```bash
cd backend
python3.11 -m venv venv
python3.12 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

View File

@@ -11,10 +11,10 @@
## Quick Start
```bash
# Prerequisites: Docker, Python 3.11+, Node.js 20+
# Prerequisites: Docker, Python 3.12, Node.js 20+
# Start PostgreSQL
docker start patherly_postgres
# Start PostgreSQL (and the rest of the dev stack)
docker compose -f docker-compose.dev.yml up -d
# Backend
cd backend
@@ -105,16 +105,17 @@ Every session generates timestamped, detailed notes formatted for your PSA. Engi
## Project Structure
```
patherly/
resolutionflow/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI entry point
│ │ ├── api/endpoints/ # Route handlers (35+ endpoints)
│ │ ├── api/endpoints/ # Route handlers (50+ endpoints)
│ │ ├── core/ # Config, database, permissions, security
│ │ ├── models/ # SQLAlchemy models
│ │ ├── schemas/ # Pydantic schemas
│ │ └── services/psa/ # PSA provider abstraction layer
│ ├── alembic/ # Database migrations
│ ├── scripts/ # Seed + sync scripts (incl. sync_stripe_plan_ids.py)
│ └── tests/ # Integration tests (100+)
├── frontend/
│ ├── src/
@@ -122,13 +123,19 @@ patherly/
│ │ ├── pages/ # Page components
│ │ ├── store/ # Zustand stores
│ │ └── types/ # TypeScript interfaces
├── .ai/ # Dual-agent handoff system (PROJECT_CONTEXT, HANDOFF, etc.)
├── docs/ # Design docs, plans, ConnectWise reference
├── brand-assets/ # SVGs, brand guide
├── CLAUDE.md # AI assistant project context
├── CLAUDE.md # AI assistant project context (Claude Code)
├── AGENTS.md # AI assistant project context (Codex; shared protocol with CLAUDE.md)
├── CURRENT-STATE.md # Detailed feature status
├── DESIGN-SYSTEM.md # Visual + interaction design system
├── PRODUCT.md # Design intent and brand personality
└── CHANGELOG.md # Release history
```
> The on-disk repo path is `resolutionflow/`. `patherly` is the legacy internal name — still appears in some Railway service names and the prod DB name. Treat as an alias, not canonical.
---
## Running Tests
@@ -149,10 +156,13 @@ npm run build
| Document | Purpose |
|----------|---------|
| [CLAUDE.md](CLAUDE.md) | Full project context for AI-assisted development |
| [CLAUDE.md](CLAUDE.md) | Project context for Claude Code |
| [AGENTS.md](AGENTS.md) | Project context for Codex (shared protocol with CLAUDE.md) |
| [.ai/PROJECT_CONTEXT.md](.ai/PROJECT_CONTEXT.md) | Stable architectural truth |
| [CURRENT-STATE.md](CURRENT-STATE.md) | Detailed feature status |
| [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) | Development roadmap |
| [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) | Design system (Slate & Ice) |
| [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) | Visual + interaction design system (charcoal palette + electric blue accent) |
| [PRODUCT.md](PRODUCT.md) | Design intent, users, brand personality |
| [DEV-ENV.md](DEV-ENV.md) | Development environment setup |
| [CHANGELOG.md](CHANGELOG.md) | Release history |

View File

@@ -21,4 +21,22 @@ ANTHROPIC_API_KEY=
VOYAGE_API_KEY=
# ConnectWise PSA Integration
CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
# Stripe
# Test keys from Stripe Dashboard → Developers → API keys (with Test mode toggled on).
# Webhook secret for local dev: from `stripe listen --forward-to localhost:8000/api/v1/webhooks/stripe`.
# When unset, app/core/config.py:stripe_enabled returns False and Stripe code paths short-circuit.
STRIPE_SECRET_KEY=sk_test_
STRIPE_PUBLISHABLE_KEY=pk_test_
STRIPE_WEBHOOK_SECRET=whsec_
# Self-serve cutover
# SELF_SERVE_ENABLED is the master switch for the public self-serve signup
# flow (pricing page, invite-code-optional registration). Default is false
# until Phase O cutover.
# INTERNAL_TESTER_EMAILS is a comma-separated allowlist that bypasses the
# global flag for specific users — used for prod test-mode validation
# before the public flip. Empty by default.
SELF_SERVE_ENABLED=false
INTERNAL_TESTER_EMAILS=

View File

@@ -0,0 +1,84 @@
"""add_starter_rename_team_to_enterprise
Revision ID: 4ce3e594cb87
Revises: c6cbfc534fad
Create Date: 2026-05-07 19:36:27.172082
Plan tier taxonomy reconciliation. Marketing surface and Stripe products
named "Starter / Pro / Enterprise"; backend was on "free / pro / team".
This migration:
1. Defensively migrates any existing subscriptions on plan='team' to
plan='enterprise' (dev has zero such rows; prod is expected to have
none, but the UPDATE is safe and idempotent).
2. Renames the plan_limits row 'team' -> 'enterprise'. plan_billing
and plan_feature_defaults are FK-referenced but currently empty;
the rename works because PostgreSQL allows updating PK values when
no FK rows reference them.
3. Inserts a new plan_limits row for 'starter' between free and pro.
Resource visibility (Tree.visibility, StepLibrary.visibility) also uses
the string 'team' for "shared with my account" — that is a separate
domain and is intentionally not touched.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '4ce3e594cb87'
down_revision: Union[str, None] = 'c6cbfc534fad'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("UPDATE subscriptions SET plan = 'enterprise' WHERE plan = 'team'")
op.execute("UPDATE plan_limits SET plan = 'enterprise' WHERE plan = 'team'")
op.execute("""
INSERT INTO plan_limits (
plan,
max_trees,
max_sessions_per_month,
max_users,
custom_branding,
priority_support,
export_formats,
max_ai_builds_per_month,
max_ai_builds_per_24h,
kb_accelerator_enabled,
kb_max_lifetime_conversions,
kb_batch_max_size,
kb_allowed_formats,
kb_detailed_analysis,
kb_conversational_refinement,
kb_step_library_matching,
kb_history_limit
) VALUES (
'starter',
10,
75,
1,
FALSE,
FALSE,
'["markdown", "text", "html"]'::jsonb,
15,
5,
FALSE,
NULL,
NULL,
'["txt", "paste", "md"]'::jsonb,
FALSE,
FALSE,
FALSE,
NULL
)
ON CONFLICT (plan) DO NOTHING
""")
def downgrade() -> None:
op.execute("DELETE FROM plan_limits WHERE plan = 'starter'")
op.execute("UPDATE plan_limits SET plan = 'team' WHERE plan = 'enterprise'")
op.execute("UPDATE subscriptions SET plan = 'team' WHERE plan = 'enterprise'")

View File

@@ -64,6 +64,40 @@ async def get_current_user(
return user
async def get_current_user_optional(
request: Request,
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> Optional[User]:
"""Best-effort current user for endpoints that work both anonymous and authed.
Returns None on missing/invalid/expired token instead of raising. Used by
surfaces like /config/public that anonymous clients can hit but where an
authenticated user gets a tailored response (e.g. INTERNAL_TESTER_EMAILS
allowlist override).
"""
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
if not auth_header or not auth_header.lower().startswith("bearer "):
return None
token = auth_header.split(None, 1)[1].strip()
if not token:
return None
payload = decode_token(token)
if payload is None or payload.get("type") != "access":
return None
user_id = payload.get("sub")
if user_id is None:
return None
try:
user_uuid = UUID(user_id)
except ValueError:
return None
result = await db.execute(select(User).where(User.id == user_uuid))
return result.scalar_one_or_none()
async def get_refresh_token_payload(
token: Annotated[str, Depends(oauth2_scheme)]
) -> dict:

View File

@@ -972,7 +972,7 @@ async def update_user_plan(
current_user: Annotated[User, Depends(require_admin)],
):
"""Change a user's subscription plan (super admin only)."""
if data.plan not in ("free", "pro", "team"):
if data.plan not in ("free", "pro", "starter", "enterprise"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
user, subscription = await _get_user_subscription(user_id, db)
old_plan = subscription.plan
@@ -991,7 +991,7 @@ async def update_account_plan(
current_user: Annotated[User, Depends(require_admin)],
):
"""Change an account subscription plan (super admin only)."""
if data.plan not in ("free", "pro", "team"):
if data.plan not in ("free", "pro", "starter", "enterprise"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
account, subscription = await _get_account_subscription(account_id, db)
old_plan = subscription.plan

View File

@@ -28,7 +28,7 @@ async def get_dashboard_metrics(
) or 0
paid_accounts = await db.scalar(
select(func.count()).select_from(Subscription).where(
Subscription.plan.in_(["pro", "team"])
Subscription.plan.in_(["pro", "starter", "enterprise"])
)
) or 0
total_trees = await db.scalar(

View File

@@ -47,8 +47,16 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["authentication"])
async def _store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) -> None:
"""Decode a refresh token JWT and store its hash in the database."""
async def store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) -> None:
"""Decode a refresh token JWT and store its hash in the database.
Module-public so OAuth callback endpoints (and any future token-issuing
surface) can register the JTI in the ``refresh_tokens`` table the same
way ``/auth/login`` does. Without this the first ``/auth/refresh`` call
will reject the token as "revoked" because no row exists.
Caller is responsible for committing the session.
"""
payload = decode_token(refresh_token_str)
if payload and payload.get("jti"):
token_record = RefreshToken(
@@ -142,7 +150,7 @@ async def register(
# and so paid/trial-bearing codes still apply when supplied.
if (
settings.REQUIRE_INVITE_CODE
and not settings.SELF_SERVE_ENABLED
and not settings.is_self_serve_active_for(user_data.email)
and not user_data.invite_code
):
raise HTTPException(
@@ -320,7 +328,7 @@ async def login(
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
# Store refresh token hash in DB
await _store_refresh_token(db, refresh_token_str, user.id)
await store_refresh_token(db, refresh_token_str, user.id)
await db.commit()
return Token(
@@ -355,7 +363,7 @@ async def login_json(
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
# Store refresh token hash in DB
await _store_refresh_token(db, refresh_token_str, user.id)
await store_refresh_token(db, refresh_token_str, user.id)
await db.commit()
return Token(
@@ -413,7 +421,7 @@ async def refresh_token(
new_refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
# Store new refresh token
await _store_refresh_token(db, new_refresh_token_str, user.id)
await store_refresh_token(db, new_refresh_token_str, user.id)
await db.commit()
return Token(

View File

@@ -1,31 +1,44 @@
"""Public beta signup endpoint — no auth required."""
"""Legacy beta signup endpoint — redirects to /register?from=beta.
Phase 2 (self-serve signup) makes the public register flow the canonical
front door. The old `/api/v1/beta-signup` POST endpoint is kept mounted to
preserve any external links that still hit it, but now responds with a
307 Temporary Redirect to `/register?from=beta` so the user lands in the
real signup flow. The `?from=beta` marker lets the frontend tag the
signup origin for analytics.
Note: there is no `beta_signup` database table — the original endpoint
only fired a notification email. There is therefore no waitlist to email
and no migration to run when retiring the endpoint.
"""
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, EmailStr
from app.core.email import EmailService
from fastapi import APIRouter
from fastapi.responses import RedirectResponse
from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/beta-signup", tags=["beta"])
class BetaSignupRequest(BaseModel):
email: EmailStr
# Local-dev fallback when FRONTEND_URL isn't configured. The redirect must
# be absolute — a relative URL would resolve against the API origin
# (api.resolutionflow.com), which has no /register page.
_DEFAULT_FRONTEND_URL = "http://localhost:5173"
class BetaSignupResponse(BaseModel):
success: bool
message: str
@router.post("", include_in_schema=False)
async def beta_signup_redirect() -> RedirectResponse:
"""Redirect legacy beta-signup POST to the public register page.
@router.post("", response_model=BetaSignupResponse)
async def beta_signup(data: BetaSignupRequest):
"""Collect beta interest — sends notification to beta@resolutionflow.com."""
sent = await EmailService.send_beta_signup_notification(data.email)
if not sent:
logger.warning("Beta signup recorded (email delivery skipped): %s", data.email)
return BetaSignupResponse(
success=True,
message="Thanks! We'll be in touch with beta access details.",
Returns 307 so any client following the redirect preserves the HTTP
method; the frontend treats `/register?from=beta` as the canonical
entry point and reads the `from` query param for analytics.
"""
frontend_url = settings.FRONTEND_URL or _DEFAULT_FRONTEND_URL
return RedirectResponse(
url=f"{frontend_url}/register?from=beta",
status_code=307,
)

View File

@@ -11,22 +11,31 @@ frontend codegen and other call sites if needed.
from __future__ import annotations
from fastapi import APIRouter
from typing import Annotated, Optional
from fastapi import APIRouter, Depends
from app.api.deps import get_current_user_optional
from app.core.config import settings
from app.models.user import User
from app.schemas.config import PublicConfigResponse
router = APIRouter(prefix="/config", tags=["config"])
@router.get("/public", response_model=PublicConfigResponse)
async def get_public_config() -> PublicConfigResponse:
async def get_public_config(
current_user: Annotated[Optional[User], Depends(get_current_user_optional)],
) -> PublicConfigResponse:
"""Return public-safe runtime config.
`oauth_providers` reflects which OAuth client IDs are configured server
side; the frontend uses it to render only buttons that will actually
succeed. `self_serve_enabled` is the master switch for the new public
self-serve signup flow.
self-serve signup flow; an authenticated caller whose email is on the
INTERNAL_TESTER_EMAILS allowlist sees `True` even when the global flag
is off, so internal validation in prod test mode can exercise the full
surface before the public flip.
"""
providers: list[str] = []
if settings.GOOGLE_CLIENT_ID:
@@ -34,7 +43,8 @@ async def get_public_config() -> PublicConfigResponse:
if settings.MS_CLIENT_ID:
providers.append("microsoft")
user_email = current_user.email if current_user else None
return PublicConfigResponse(
self_serve_enabled=settings.SELF_SERVE_ENABLED,
self_serve_enabled=settings.is_self_serve_active_for(user_email),
oauth_providers=providers,
)

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.endpoints.auth import store_refresh_token
from app.core.admin_database import get_admin_db
from app.core.config import settings
from app.core.security import create_access_token, create_refresh_token
@@ -186,9 +187,16 @@ async def google_callback(
account_invite_code=payload.account_invite_code,
invited_email=payload.invited_email,
)
refresh_token_str = create_refresh_token({"sub": str(user.id)})
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
# reject this token as "revoked" (the rotation logic requires a row to
# mark as used). _sign_in_or_register already committed; this needs a
# second commit.
await store_refresh_token(db, refresh_token_str, user.id)
await db.commit()
return OAuthCallbackResponse(
access_token=create_access_token({"sub": str(user.id)}),
refresh_token=create_refresh_token({"sub": str(user.id)}),
refresh_token=refresh_token_str,
is_new_user=is_new,
)
@@ -209,8 +217,15 @@ async def microsoft_callback(
account_invite_code=payload.account_invite_code,
invited_email=payload.invited_email,
)
refresh_token_str = create_refresh_token({"sub": str(user.id)})
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
# reject this token as "revoked" (the rotation logic requires a row to
# mark as used). _sign_in_or_register already committed; this needs a
# second commit.
await store_refresh_token(db, refresh_token_str, user.id)
await db.commit()
return OAuthCallbackResponse(
access_token=create_access_token({"sub": str(user.id)}),
refresh_token=create_refresh_token({"sub": str(user.id)}),
refresh_token=refresh_token_str,
is_new_user=is_new,
)

View File

@@ -0,0 +1,58 @@
"""Public plans endpoint — no auth required.
GET /api/v1/plans/public
Returns the public-safe view of `plan_billing` joined with
`plan_limits.max_users` (exposed as `max_seats`), filtered to
`is_public=True AND is_archived=False`, ordered by sort_order ASC, plan ASC.
Distinct from `/admin/plan-limits` (admin-only, returns ALL plans including
archived/internal). This endpoint exists to power the marketing /pricing page
without exposing the rest of the admin-only billing surface.
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.admin_database import get_admin_db
from app.models.plan_billing import PlanBilling
from app.models.plan_limits import PlanLimits
from app.schemas.billing import PublicPlanResponse
router = APIRouter(prefix="/plans", tags=["plans"])
@router.get("/public", response_model=list[PublicPlanResponse])
async def list_public_plans(
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> list[PublicPlanResponse]:
"""List public, non-archived plans for the marketing /pricing page.
Public — no auth. Uses `get_admin_db` because this is a cross-tenant read
of the global plan catalog (same pattern as `/config/public`).
"""
stmt = (
select(PlanBilling, PlanLimits.max_users)
.outerjoin(PlanLimits, PlanBilling.plan == PlanLimits.plan)
.where(PlanBilling.is_public.is_(True))
.where(PlanBilling.is_archived.is_(False))
.order_by(PlanBilling.sort_order.asc(), PlanBilling.plan.asc())
)
rows = (await db.execute(stmt)).all()
return [
PublicPlanResponse(
plan=billing.plan,
display_name=billing.display_name,
description=billing.description,
monthly_price_cents=billing.monthly_price_cents,
annual_price_cents=billing.annual_price_cents,
max_seats=max_users,
sort_order=billing.sort_order,
is_public=billing.is_public,
)
for billing, max_users in rows
]

View File

@@ -45,6 +45,7 @@ from app.api.endpoints import (
notifications,
oauth as oauth_endpoints,
onboarding,
plans_public,
public_templates,
ratings,
scripts,
@@ -97,6 +98,7 @@ api_router.include_router(public_templates.router) # Public gallery (no auth, r
api_router.include_router(survey.router) # Public survey flow (no auth, rate-limited)
api_router.include_router(config_endpoints.router) # Public runtime feature flags
api_router.include_router(account_invite_lookup.router) # Public invite-code lookup for /accept-invite
api_router.include_router(plans_public.router) # Public plan catalog for /pricing page
# ---------------------------------------------------------------------------
# Admin endpoints — super_admin only

View File

@@ -97,6 +97,40 @@ class Settings(BaseSettings):
STRIPE_WEBHOOK_SECRET: Optional[str] = None
SELF_SERVE_ENABLED: bool = False
# Internal tester allowlist for soft cutover. Comma-separated emails;
# when SELF_SERVE_ENABLED is False, listed users still see the self-serve
# surfaces (pricing page, invite-code-optional registration, etc.) so the
# full flow can be exercised in prod test mode before public flip.
INTERNAL_TESTER_EMAILS: list[str] = []
@field_validator("INTERNAL_TESTER_EMAILS", mode="before")
@classmethod
def split_internal_tester_emails(cls, v) -> list[str]:
"""Parse a comma-separated string into a normalized lowercase list."""
if v is None or v == "":
return []
if isinstance(v, list):
return [e.strip().lower() for e in v if e and e.strip()]
if isinstance(v, str):
return [e.strip().lower() for e in v.split(",") if e.strip()]
return []
def is_internal_tester(self, email: Optional[str]) -> bool:
"""Case-insensitive allowlist check. None/empty email is never a tester."""
if not email:
return False
return email.lower() in self.INTERNAL_TESTER_EMAILS
def is_self_serve_active_for(self, email: Optional[str]) -> bool:
"""True if self-serve surfaces should render for this user.
Either the global flag is on, or the user is on the internal-tester
allowlist. Anonymous calls (email is None) only see the global flag.
"""
if self.SELF_SERVE_ENABLED:
return True
return self.is_internal_tester(email)
@property
def stripe_enabled(self) -> bool:
"""Check if Stripe is configured."""

View File

@@ -37,12 +37,12 @@ class Subscription(Base):
@property
def is_paid(self) -> bool:
# Excludes complimentary and trialing so MRR/paid-customer metrics aren't inflated.
return self.plan in ("pro", "team") and self.status not in ("complimentary", "trialing")
return self.plan in ("pro", "starter", "enterprise") and self.status not in ("complimentary", "trialing")
@property
def has_pro_entitlement(self) -> bool:
"""True if the account can access Pro features right now."""
if self.plan in ("pro", "team"):
if self.plan in ("pro", "starter", "enterprise"):
if self.status in ("active", "complimentary"):
return True
if self.status == "trialing" and self.current_period_end is not None:

View File

@@ -125,7 +125,7 @@ class AdminAccountDetailResponse(AdminAccountListItem):
class AdminAccountCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
plan: Literal["free", "pro", "team"] = "free"
plan: Literal["free", "pro", "starter", "enterprise"] = "free"
owner_email: Optional[EmailStr] = Field(None, description="Email of an existing user to set as owner")

View File

@@ -4,7 +4,7 @@ from pydantic import BaseModel
class CheckoutSessionCreate(BaseModel):
plan: Literal["pro", "starter", "team", "enterprise"]
plan: Literal["pro", "starter", "enterprise"]
seats: int
billing_interval: Literal["monthly", "annual"] = "monthly"
@@ -42,3 +42,23 @@ class BillingStateResponse(BaseModel):
plan_billing: Optional[PlanBillingState]
plan_limits: Dict[str, Any]
enabled_features: Dict[str, bool]
class PublicPlanResponse(BaseModel):
"""Public-safe view of a billable plan, used by the marketing /pricing page.
Sourced from `plan_billing` joined with `plan_limits.max_users` (exposed
here as `max_seats`). Always filtered server-side to is_public=True and
is_archived=False, so `is_public` is a constant True for any row returned
here — included for clarity and forward compatibility.
"""
plan: str
display_name: str
description: Optional[str] = None
monthly_price_cents: Optional[int] = None
annual_price_cents: Optional[int] = None
max_seats: Optional[int] = None
sort_order: int
is_public: bool = True
model_config = {"from_attributes": True}

View File

@@ -9,7 +9,7 @@ class InviteCodeCreate(BaseModel):
expires_at: Optional[datetime] = Field(None, description="Optional expiration time")
note: Optional[str] = Field(None, max_length=255, description="Note about who this code is for")
email: Optional[EmailStr] = Field(None, description="Recipient email for invite delivery")
assigned_plan: Literal["free", "pro", "team"] = Field("free", description="Plan to assign on registration")
assigned_plan: Literal["free", "pro", "starter", "enterprise"] = Field("free", description="Plan to assign on registration")
trial_duration_days: Optional[int] = Field(None, ge=1, le=90, description="Trial duration in days (1-90)")
@model_validator(mode="after")

View File

@@ -41,7 +41,7 @@ class SubscriptionDetails(BaseModel):
class SubscriptionPlanUpdate(BaseModel):
plan: str # free, pro, team
plan: str # free, pro, starter, enterprise
model_config = {"json_schema_extra": {"examples": [{"plan": "pro"}]}}

View File

@@ -210,28 +210,44 @@ class BillingService:
) -> bool:
"""Idempotent. Returns True if the event was applied; False if it had
already been processed (idempotent ack). The webhook handler returns 200
either way."""
either way.
Atomic: the StripeEvent idempotency mark and the handler's state
mutations are committed in a single transaction. If the handler raises
the entire transaction (idempotency mark + partial mutations) is rolled
back, so a Stripe retry will re-run the handler. Without this, a
handler that fails mid-flight would leave the StripeEvent row persisted
and silently desync subscription state from Stripe.
"""
db.add(StripeEvent(
id=event_id,
event_type=event_type,
payload_excerpt=_excerpt(payload),
))
try:
db.add(StripeEvent(
id=event_id,
event_type=event_type,
payload_excerpt=_excerpt(payload),
))
await db.commit()
await db.flush()
except IntegrityError:
# Duplicate event_id — already processed (or in flight). Ack with False.
await db.rollback()
return False
if event_type == "checkout.session.completed":
await _handle_checkout_completed(db, payload)
elif event_type == "customer.subscription.updated":
await _handle_subscription_updated(db, payload)
elif event_type == "customer.subscription.deleted":
await _handle_subscription_deleted(db, payload)
elif event_type == "invoice.payment_failed":
await _handle_payment_failed(db, payload)
elif event_type == "invoice.payment_succeeded":
await _handle_payment_succeeded(db, payload)
try:
if event_type == "checkout.session.completed":
await _handle_checkout_completed(db, payload)
elif event_type == "customer.subscription.updated":
await _handle_subscription_updated(db, payload)
elif event_type == "customer.subscription.deleted":
await _handle_subscription_deleted(db, payload)
elif event_type == "invoice.payment_failed":
await _handle_payment_failed(db, payload)
elif event_type == "invoice.payment_succeeded":
await _handle_payment_succeeded(db, payload)
await db.commit()
except Exception:
# Roll back the StripeEvent insert + any partial handler mutations
# so Stripe's retry can re-run cleanly.
await db.rollback()
raise
return True
@@ -282,7 +298,7 @@ async def _handle_checkout_completed(db: AsyncSession, payload: dict):
)).scalar_one_or_none()
if pb is not None:
sub.plan = pb.plan
await db.commit()
# No commit — apply_subscription_event commits once for the full event.
async def _handle_subscription_updated(db: AsyncSession, payload: dict):
@@ -297,7 +313,7 @@ async def _handle_subscription_updated(db: AsyncSession, payload: dict):
sub.current_period_end = datetime.fromtimestamp(obj["current_period_end"], tz=timezone.utc)
sub.cancel_at_period_end = obj.get("cancel_at_period_end", False)
sub.seat_limit = obj["items"]["data"][0]["quantity"]
await db.commit()
# No commit — apply_subscription_event commits once for the full event.
async def _handle_subscription_deleted(db: AsyncSession, payload: dict):
@@ -308,7 +324,7 @@ async def _handle_subscription_deleted(db: AsyncSession, payload: dict):
if sub is None:
return
sub.status = "canceled"
await db.commit()
# No commit — apply_subscription_event commits once for the full event.
async def _handle_payment_failed(db: AsyncSession, payload: dict):
@@ -322,7 +338,7 @@ async def _handle_payment_failed(db: AsyncSession, payload: dict):
if sub is None:
return
sub.status = "past_due"
await db.commit()
# No commit — apply_subscription_event commits once for the full event.
async def _handle_payment_succeeded(db: AsyncSession, payload: dict):
@@ -337,4 +353,4 @@ async def _handle_payment_succeeded(db: AsyncSession, payload: dict):
return
if sub.status == "past_due":
sub.status = "active"
await db.commit()
# No commit — apply_subscription_event commits once for the full event.

View File

@@ -97,7 +97,18 @@ async def main() -> None:
)
row = result.first()
if row:
print(f" [SKIP] {cfg['email']} already exists")
# Backfill email_verified_at for existing rows so older test
# users created before this script set the field still bypass
# the 7-day verification grace.
await conn.execute(
text("""
UPDATE users
SET email_verified_at = COALESCE(email_verified_at, :now)
WHERE email = :email
"""),
{"email": cfg["email"], "now": now},
)
print(f" [SKIP] {cfg['email']} already exists (email_verified_at backfilled if null)")
if cfg["key"] == "team_admin":
team_account_id = row.account_id
continue
@@ -130,12 +141,17 @@ async def main() -> None:
# ---- Create User ----
user_id = uuid.uuid4()
# email_verified_at is stamped at seed time so test users bypass the
# 7-day verification grace immediately. Without this, fixtures hit
# require_verified_email_after_grace once their created_at ages past
# 7 days and get walled out of protected routes.
await conn.execute(
text("""
INSERT INTO users (id, email, password_hash, name, role, is_super_admin,
is_team_admin, is_active, account_id, account_role, created_at)
is_team_admin, is_active, account_id, account_role,
created_at, email_verified_at)
VALUES (:id, :email, :pw, :name, 'engineer', :is_sa, :is_ta, true,
:account_id, :account_role, :now)
:account_id, :account_role, :now, :now)
"""),
{
"id": user_id,

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Sync plan_billing rows from Stripe products and prices.
Reads the active Stripe environment (test or live, determined by
STRIPE_SECRET_KEY in env), looks up the canonical ResolutionFlow products
by exact name match, picks the active monthly recurring price for tiers
that have one, and upserts plan_billing rows.
Idempotent. Safe to re-run after price changes, after live cutover, or
after rotating Stripe keys.
Tier mapping (name in Stripe -> plan slug in plan_limits):
ResolutionFlow Starter -> starter (monthly price required)
ResolutionFlow Pro -> pro (monthly price required)
ResolutionFlow Enterprise -> enterprise (no price, sales-led)
Annual prices are intentionally not supported in this iteration. The
plan_billing schema allows annual fields (stripe_annual_price_id,
annual_price_cents); this script leaves them NULL.
Usage:
docker exec -w /app resolutionflow_backend python -m scripts.sync_stripe_plan_ids
docker exec -w /app resolutionflow_backend python -m scripts.sync_stripe_plan_ids --dry-run
"""
import argparse
import asyncio
import logging
import sys
from typing import Optional
import stripe
from app.core.config import settings
from app.core.database import async_session_maker
from sqlalchemy import text
logger = logging.getLogger("sync_stripe_plan_ids")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
PLAN_NAME_TO_SLUG = {
"ResolutionFlow Starter": "starter",
"ResolutionFlow Pro": "pro",
"ResolutionFlow Enterprise": "enterprise",
}
PLANS_REQUIRING_PRICE = {"starter", "pro"}
PLAN_DEFAULTS = {
"starter": {"sort_order": 10, "is_public": True},
"pro": {"sort_order": 20, "is_public": True},
"enterprise": {"sort_order": 30, "is_public": True},
}
def find_product_by_name(target: str) -> Optional[stripe.Product]:
"""Page through active products and return the first exact name match."""
for product in stripe.Product.list(active=True, limit=100).auto_paging_iter():
if product.name == target:
return product
return None
def find_active_monthly_price(product_id: str) -> Optional[stripe.Price]:
"""Return the active recurring monthly price for a product, or None."""
candidates = [
p
for p in stripe.Price.list(product=product_id, active=True, limit=100).auto_paging_iter()
if p.type == "recurring"
and p.recurring is not None
and p.recurring.get("interval") == "month"
and p.recurring.get("interval_count", 1) == 1
]
if not candidates:
return None
if len(candidates) > 1:
logger.warning(
"Product %s has %d active monthly recurring prices; picking %s. "
"Archive the others to silence this warning.",
product_id, len(candidates), candidates[0].id,
)
return candidates[0]
async def upsert_plan_billing(
plan: str,
display_name: str,
description: Optional[str],
monthly_price_cents: Optional[int],
stripe_product_id: Optional[str],
stripe_monthly_price_id: Optional[str],
sort_order: int,
is_public: bool,
dry_run: bool,
) -> None:
"""Upsert one plan_billing row. Annual fields stay NULL."""
if dry_run:
logger.info(
"[dry-run] would upsert plan=%s display=%s monthly_cents=%s "
"product=%s monthly_price=%s",
plan, display_name, monthly_price_cents,
stripe_product_id, stripe_monthly_price_id,
)
return
sql = text("""
INSERT INTO plan_billing (
plan, display_name, description,
monthly_price_cents, annual_price_cents,
stripe_product_id, stripe_monthly_price_id, stripe_annual_price_id,
is_public, is_archived, sort_order
) VALUES (
:plan, :display_name, :description,
:monthly_price_cents, NULL,
:stripe_product_id, :stripe_monthly_price_id, NULL,
:is_public, FALSE, :sort_order
)
ON CONFLICT (plan) DO UPDATE SET
display_name = EXCLUDED.display_name,
description = EXCLUDED.description,
monthly_price_cents = EXCLUDED.monthly_price_cents,
stripe_product_id = EXCLUDED.stripe_product_id,
stripe_monthly_price_id = EXCLUDED.stripe_monthly_price_id,
is_public = EXCLUDED.is_public,
sort_order = EXCLUDED.sort_order,
updated_at = NOW()
""")
async with async_session_maker() as session:
await session.execute(sql, {
"plan": plan,
"display_name": display_name,
"description": description,
"monthly_price_cents": monthly_price_cents,
"stripe_product_id": stripe_product_id,
"stripe_monthly_price_id": stripe_monthly_price_id,
"is_public": is_public,
"sort_order": sort_order,
})
await session.commit()
logger.info("upserted plan_billing for plan=%s", plan)
async def main(dry_run: bool) -> int:
if not settings.STRIPE_SECRET_KEY:
logger.error("STRIPE_SECRET_KEY is not set. Refusing to run.")
return 2
stripe.api_key = settings.STRIPE_SECRET_KEY
mode = "live" if settings.STRIPE_SECRET_KEY.startswith("sk_live_") else "test"
logger.info("connected to Stripe in %s mode", mode)
errors: list[str] = []
for product_name, plan in PLAN_NAME_TO_SLUG.items():
defaults = PLAN_DEFAULTS[plan]
product = find_product_by_name(product_name)
if product is None:
errors.append(f"Stripe product not found: {product_name!r}")
continue
price = None
if plan in PLANS_REQUIRING_PRICE:
price = find_active_monthly_price(product.id)
if price is None:
errors.append(
f"No active monthly recurring price for {product_name!r} "
f"(product {product.id})"
)
continue
await upsert_plan_billing(
plan=plan,
display_name=product.name,
description=product.description,
monthly_price_cents=price.unit_amount if price else None,
stripe_product_id=product.id,
stripe_monthly_price_id=price.id if price else None,
sort_order=defaults["sort_order"],
is_public=defaults["is_public"],
dry_run=dry_run,
)
if errors:
for e in errors:
logger.error(e)
return 1
logger.info("done")
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true", help="Log actions without writing.")
args = parser.parse_args()
sys.exit(asyncio.run(main(dry_run=args.dry_run)))

View File

@@ -172,8 +172,9 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
INSERT INTO plan_limits (plan, max_trees, max_sessions_per_month, max_users, custom_branding, priority_support, export_formats)
VALUES
('free', 3, 20, 1, false, false, '["markdown", "text"]'),
('starter', 10, 75, 1, false, false, '["markdown", "text", "html"]'),
('pro', 25, 200, 5, true, false, '["markdown", "text", "html"]'),
('team', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]')
('enterprise', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]')
"""))
# Seed the platform/system account (PLATFORM_ACCOUNT_ID) needed by

View File

@@ -122,9 +122,9 @@ class TestAdminPlanLimits:
):
"""PUT /admin/plan-limits upserts a plan_billing row when billing
fields are included in the body."""
# Ensure no plan_billing row exists for "team" yet.
# Ensure no plan_billing row exists for "enterprise" yet.
existing = (await test_db.execute(
select(PlanBilling).where(PlanBilling.plan == "team")
select(PlanBilling).where(PlanBilling.plan == "enterprise")
)).scalar_one_or_none()
if existing is not None:
await test_db.delete(existing)
@@ -133,7 +133,7 @@ class TestAdminPlanLimits:
response = await client.put(
"/api/v1/admin/plan-limits",
json={
"plan": "team",
"plan": "enterprise",
"max_trees": None,
"max_sessions_per_month": None,
"max_users": None,
@@ -163,7 +163,7 @@ class TestAdminPlanLimits:
# Confirm the row was actually persisted.
await test_db.commit() # ensure session sees other-session writes
pb = (await test_db.execute(
select(PlanBilling).where(PlanBilling.plan == "team")
select(PlanBilling).where(PlanBilling.plan == "enterprise")
)).scalar_one_or_none()
assert pb is not None
assert pb.display_name == "Team"
@@ -179,17 +179,17 @@ class TestAdminPlanLimits:
plan_billing row when the caller passes explicit nulls. The set of
guarded fields is {display_name, is_public, is_archived, sort_order}.
"""
# Seed a plan_billing row for "team" with non-default values for every
# Seed a plan_billing row for "enterprise" with non-default values for every
# NOT NULL field so we can detect any clobbering.
existing = (await test_db.execute(
select(PlanBilling).where(PlanBilling.plan == "team")
select(PlanBilling).where(PlanBilling.plan == "enterprise")
)).scalar_one_or_none()
if existing is not None:
await test_db.delete(existing)
await test_db.commit()
seeded = PlanBilling(
plan="team",
plan="enterprise",
display_name="Team Seeded",
is_public=False,
is_archived=True,
@@ -201,7 +201,7 @@ class TestAdminPlanLimits:
response = await client.put(
"/api/v1/admin/plan-limits",
json={
"plan": "team",
"plan": "enterprise",
"max_trees": None,
"max_sessions_per_month": None,
"max_users": None,
@@ -221,7 +221,7 @@ class TestAdminPlanLimits:
# Confirm the seeded NOT NULL values were preserved.
await test_db.commit() # ensure session sees writes from the request
pb = (await test_db.execute(
select(PlanBilling).where(PlanBilling.plan == "team")
select(PlanBilling).where(PlanBilling.plan == "enterprise")
)).scalar_one_or_none()
assert pb is not None
assert pb.display_name == "Team Seeded"

View File

@@ -0,0 +1,43 @@
"""Integration tests for the legacy /beta-signup redirect.
Phase 2 retires the public beta-signup form in favor of the regular
register flow. The endpoint stays mounted but answers with a 307 to
the absolute frontend `/register?from=beta` URL so any external links
keep working. There is no `beta_signup` table to migrate — the old
endpoint only fired an email notification — so this test only covers
the redirect contract.
"""
import pytest
from app.core.config import settings
@pytest.mark.asyncio
async def test_beta_signup_redirects_to_register(client, monkeypatch):
"""POST /beta-signup returns 307 to the absolute frontend register URL."""
monkeypatch.setattr(settings, "FRONTEND_URL", "https://example.com")
response = await client.post(
"/api/v1/beta-signup",
json={"email": "anyone@example.com"},
)
assert response.status_code == 307, response.text
assert (
response.headers["location"]
== "https://example.com/register?from=beta"
)
@pytest.mark.asyncio
async def test_beta_signup_redirect_ignores_body(client, monkeypatch):
"""Redirect fires regardless of payload — no validation on the legacy route."""
monkeypatch.setattr(settings, "FRONTEND_URL", "https://example.com")
response = await client.post("/api/v1/beta-signup", json={})
assert response.status_code == 307
assert (
response.headers["location"]
== "https://example.com/register?from=beta"
)

View File

@@ -49,6 +49,58 @@ class TestConfigPublic:
assert response.status_code == 200
assert response.json()["oauth_providers"] == ["microsoft"]
@pytest.mark.asyncio
async def test_get_config_public_returns_true_for_internal_tester(
self,
client: AsyncClient,
auth_headers: dict,
test_user: dict,
monkeypatch: pytest.MonkeyPatch,
):
"""Authenticated user whose email is on INTERNAL_TESTER_EMAILS sees
self_serve_enabled=True even when the global flag is off."""
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", [test_user["email"].lower()])
response = await client.get("/api/v1/config/public", headers=auth_headers)
assert response.status_code == 200
assert response.json()["self_serve_enabled"] is True
@pytest.mark.asyncio
async def test_get_config_public_returns_false_for_non_tester_when_global_off(
self,
client: AsyncClient,
auth_headers: dict,
monkeypatch: pytest.MonkeyPatch,
):
"""Authenticated user NOT on the allowlist sees the global flag —
prevents accidental opt-in via stale credentials or empty allowlist."""
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", ["someone-else@example.com"])
response = await client.get("/api/v1/config/public", headers=auth_headers)
assert response.status_code == 200
assert response.json()["self_serve_enabled"] is False
@pytest.mark.asyncio
async def test_get_config_public_anonymous_ignores_allowlist(
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
):
"""Anonymous callers always see the global flag — the allowlist is
keyed on authenticated identity, not request content."""
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", ["anon-tester@example.com"])
response = await client.get("/api/v1/config/public")
assert response.status_code == 200
assert response.json()["self_serve_enabled"] is False
class TestRegisterInviteCodeGate:
"""Regression + new-behavior tests for /auth/register vs SELF_SERVE_ENABLED."""
@@ -98,3 +150,55 @@ class TestRegisterInviteCodeGate:
assert body["email"] == "self-serve@example.com"
assert body["account_role"] == "owner"
assert "account_id" in body
@pytest.mark.asyncio
async def test_register_invite_code_optional_for_internal_tester(
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
):
"""SELF_SERVE_ENABLED is False but the registering email is on
INTERNAL_TESTER_EMAILS — registration should succeed without an
invite code, matching the per-email soft-cutover behavior."""
monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True)
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
monkeypatch.setattr(
settings, "INTERNAL_TESTER_EMAILS", ["tester@example.com"]
)
response = await client.post(
"/api/v1/auth/register",
json={
"email": "tester@example.com",
"password": "SecurePass123!",
"name": "Internal Tester",
},
)
assert response.status_code == 201, response.text
body = response.json()
assert body["email"] == "tester@example.com"
assert body["account_role"] == "owner"
@pytest.mark.asyncio
async def test_register_blocked_for_non_tester_when_self_serve_disabled(
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
):
"""Registering with an email NOT on the allowlist still 400s when
self-serve is off and no invite code is provided. Prevents the
allowlist from leaking to public users."""
monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True)
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
monkeypatch.setattr(
settings, "INTERNAL_TESTER_EMAILS", ["other@example.com"]
)
response = await client.post(
"/api/v1/auth/register",
json={
"email": "outsider@example.com",
"password": "SecurePass123!",
"name": "Outsider",
},
)
assert response.status_code == 400
assert "invite code is required" in response.json()["detail"].lower()

View File

@@ -49,7 +49,7 @@ class TestInviteCodeCreation:
):
response = await client.post(
"/api/v1/invites",
json={"assigned_plan": "team", "email": "beta@example.com"},
json={"assigned_plan": "enterprise", "email": "beta@example.com"},
headers=admin_auth_headers,
)
assert response.status_code == 201
@@ -149,7 +149,7 @@ class TestRegistrationWithInvitePlan:
# Create team invite without trial
resp = await client.post(
"/api/v1/invites",
json={"assigned_plan": "team"},
json={"assigned_plan": "enterprise"},
headers=admin_auth_headers,
)
code = resp.json()["code"]
@@ -172,7 +172,7 @@ class TestRegistrationWithInvitePlan:
sub = (await test_db.execute(
select(Subscription).where(Subscription.account_id == user.account_id)
)).scalar_one()
assert sub.plan == "team"
assert sub.plan == "enterprise"
assert sub.status == "active"

View File

@@ -2,8 +2,10 @@ import uuid
import pytest
from unittest.mock import patch
from sqlalchemy import select
from app.core.security import decode_token, hash_token
from app.models.user import User
from app.models.oauth_identity import OAuthIdentity
from app.models.refresh_token import RefreshToken
from app.models.subscription import Subscription
from app.services.oauth_providers import OAuthProfile
@@ -118,3 +120,77 @@ async def test_microsoft_callback_creates_user(client, test_db, monkeypatch):
select(OAuthIdentity).where(OAuthIdentity.user_id == user.id)
)).scalar_one()
assert identity.provider == "microsoft"
@pytest.mark.asyncio
async def test_oauth_google_callback_stores_refresh_token_jti(
client, test_db, monkeypatch
):
"""A successful Google OAuth callback must persist the refresh-token JTI
in the refresh_tokens table — otherwise /auth/refresh rejects it."""
from app.core.config import settings
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
profile = OAuthProfile(
provider_subject="google_subject_jti_test",
email="jtitest@example.com",
name="JTI Test",
)
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
response = await client.post(
"/api/v1/auth/google/callback", json={"code": "auth_code_xyz"}
)
assert response.status_code == 200, response.json()
body = response.json()
refresh_token_str = body["refresh_token"]
payload = decode_token(refresh_token_str)
assert payload is not None
jti = payload["jti"]
token_hash = hash_token(jti)
user = (await test_db.execute(
select(User).where(User.email == "jtitest@example.com")
)).scalar_one()
stored = (await test_db.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)).scalar_one_or_none()
assert stored is not None, "OAuth callback did not persist refresh-token JTI"
assert stored.user_id == user.id
assert stored.revoked_at is None
@pytest.mark.asyncio
async def test_oauth_refresh_works_after_oauth_signup(
client, test_db, monkeypatch
):
"""End-to-end: OAuth callback issues a refresh token; calling /auth/refresh
with that token must succeed (not be rejected as revoked)."""
from app.core.config import settings
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
profile = OAuthProfile(
provider_subject="google_subject_refresh_test",
email="refresh-after-oauth@example.com",
name="Refresh After OAuth",
)
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
callback_resp = await client.post(
"/api/v1/auth/google/callback", json={"code": "auth_code_xyz"}
)
assert callback_resp.status_code == 200, callback_resp.json()
refresh_token_str = callback_resp.json()["refresh_token"]
refresh_resp = await client.post(
"/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {refresh_token_str}"},
)
assert refresh_resp.status_code == 200, refresh_resp.json()
refreshed = refresh_resp.json()
assert refreshed["access_token"]
assert refreshed["refresh_token"]
# Token rotation: new refresh token differs from the original.
assert refreshed["refresh_token"] != refresh_token_str

View File

@@ -0,0 +1,139 @@
"""Integration tests for the public plans endpoint.
Covers GET /api/v1/plans/public — the marketing /pricing page data source.
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import delete
from app.models.plan_billing import PlanBilling
from app.models.plan_limits import PlanLimits
async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None:
"""Ensure a plan_limits row exists with the given max_users.
Upserts: conftest seeds the canonical plans (free/starter/pro/enterprise)
so this helper has to overwrite max_users when a test wants different
values for fixture-driven assertions.
"""
existing = await test_db.get(PlanLimits, plan)
if existing is None:
test_db.add(
PlanLimits(
plan=plan,
max_trees=None,
max_sessions_per_month=None,
max_users=max_users,
custom_branding=False,
priority_support=False,
export_formats=["markdown", "text"],
)
)
else:
existing.max_users = max_users
await test_db.commit()
class TestGetPlansPublic:
"""GET /api/v1/plans/public — anonymous, no auth."""
@pytest.mark.asyncio
async def test_get_plans_public_returns_only_is_public_rows(
self, client: AsyncClient, test_db
):
"""Rows with is_public=False or is_archived=True must NOT appear."""
# Wipe any existing billing rows so this test owns the fixture state.
await test_db.execute(delete(PlanBilling))
await test_db.commit()
await _seed_plan_limits(test_db, "starter", 3)
await _seed_plan_limits(test_db, "pro", 10)
await _seed_plan_limits(test_db, "internal", None)
await _seed_plan_limits(test_db, "legacy", 5)
test_db.add_all(
[
PlanBilling(
plan="starter",
display_name="Starter",
monthly_price_cents=1900,
is_public=True,
is_archived=False,
sort_order=10,
),
PlanBilling(
plan="pro",
display_name="Pro",
monthly_price_cents=4900,
is_public=True,
is_archived=False,
sort_order=20,
),
PlanBilling(
plan="internal",
display_name="Internal",
is_public=False, # hidden
is_archived=False,
sort_order=30,
),
PlanBilling(
plan="legacy",
display_name="Legacy",
is_public=True,
is_archived=True, # archived
sort_order=40,
),
]
)
await test_db.commit()
response = await client.get("/api/v1/plans/public")
assert response.status_code == 200
plans = response.json()
plan_names = {p["plan"] for p in plans}
assert "starter" in plan_names
assert "pro" in plan_names
assert "internal" not in plan_names
assert "legacy" not in plan_names
# Schema sanity check
starter = next(p for p in plans if p["plan"] == "starter")
assert starter["display_name"] == "Starter"
assert starter["monthly_price_cents"] == 1900
assert starter["max_seats"] == 3
assert starter["is_public"] is True
@pytest.mark.asyncio
async def test_get_plans_public_orders_by_sort_order_then_plan(
self, client: AsyncClient, test_db
):
"""Result must be ordered by sort_order ASC, then plan name ASC."""
await test_db.execute(delete(PlanBilling))
await test_db.commit()
# plan_limits rows for FK satisfaction
for name in ("alpha", "bravo", "charlie", "delta"):
await _seed_plan_limits(test_db, name, None)
# Two with sort_order=10 (charlie should come before delta by plan ASC),
# one with sort_order=5 (alpha first overall),
# one with sort_order=20 (bravo last).
test_db.add_all(
[
PlanBilling(plan="charlie", display_name="C", sort_order=10, is_public=True, is_archived=False),
PlanBilling(plan="delta", display_name="D", sort_order=10, is_public=True, is_archived=False),
PlanBilling(plan="alpha", display_name="A", sort_order=5, is_public=True, is_archived=False),
PlanBilling(plan="bravo", display_name="B", sort_order=20, is_public=True, is_archived=False),
]
)
await test_db.commit()
response = await client.get("/api/v1/plans/public")
assert response.status_code == 200
ordered = [p["plan"] for p in response.json()]
assert ordered == ["alpha", "charlie", "delta", "bravo"]

View File

@@ -142,3 +142,178 @@ async def test_webhook_idempotency(
assert r2.status_code == 200
assert r1.json()["applied"] is True
assert r2.json()["applied"] is False
# ----------------------------------------------------------------------------
# Atomic-idempotency regression tests
# ----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_apply_event_handler_failure_does_not_persist_idempotency_mark(
test_db, test_user,
):
"""If the handler raises, the StripeEvent row must NOT be persisted —
otherwise Stripe's retry will be silently dropped as a duplicate and the
subscription state will desync from Stripe."""
from app.services.billing import BillingService
from app.models.stripe_event import StripeEvent
event_id = "evt_handler_fail_1"
payload = {"data": {"object": {
"id": "sub_doesnotmatter",
"status": "active",
"current_period_start": 1714521600,
"current_period_end": 1717113600,
"items": {"data": [{"quantity": 1}]},
"cancel_at_period_end": False,
}}}
boom = RuntimeError("simulated handler failure")
with patch(
"app.services.billing._handle_subscription_updated",
side_effect=boom,
):
with pytest.raises(RuntimeError, match="simulated handler failure"):
await BillingService.apply_subscription_event(
test_db,
event_id=event_id,
event_type="customer.subscription.updated",
payload=payload,
)
# The StripeEvent row must not exist — handler raised, the entire
# transaction (idempotency mark + partial mutations) was rolled back.
row = (await test_db.execute(
select(StripeEvent).where(StripeEvent.id == event_id)
)).scalar_one_or_none()
assert row is None, (
"StripeEvent row was persisted despite handler failure — "
"Stripe retry will be silently dropped"
)
@pytest.mark.asyncio
async def test_apply_event_retry_after_failure_succeeds(
test_db, test_user,
):
"""A failed first attempt followed by a successful retry must apply state.
This is the core Stripe webhook retry contract."""
from app.services.billing import BillingService
from app.models.stripe_event import StripeEvent
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
test_db.add(Subscription(
account_id=account_id, plan="pro", status="trialing",
stripe_subscription_id="sub_retry",
))
await test_db.commit()
event_id = "evt_retry_1"
payload = {"data": {"object": {
"id": "sub_retry",
"status": "active",
"current_period_start": 1714521600,
"current_period_end": 1717113600,
"items": {"data": [{"quantity": 3}]},
"cancel_at_period_end": False,
}}}
# First attempt — handler raises mid-flight.
with patch(
"app.services.billing._handle_subscription_updated",
side_effect=RuntimeError("transient blip"),
):
with pytest.raises(RuntimeError):
await BillingService.apply_subscription_event(
test_db,
event_id=event_id,
event_type="customer.subscription.updated",
payload=payload,
)
# No idempotency mark, sub still trialing.
row = (await test_db.execute(
select(StripeEvent).where(StripeEvent.id == event_id)
)).scalar_one_or_none()
assert row is None
sub = (await test_db.execute(
select(Subscription).where(Subscription.account_id == account_id)
)).scalar_one()
assert sub.status == "trialing"
# Second attempt — same event_id, handler succeeds.
applied = await BillingService.apply_subscription_event(
test_db,
event_id=event_id,
event_type="customer.subscription.updated",
payload=payload,
)
assert applied is True
# Idempotency mark now persisted, sub state reconciled.
row = (await test_db.execute(
select(StripeEvent).where(StripeEvent.id == event_id)
)).scalar_one()
assert row.id == event_id
await test_db.refresh(sub)
assert sub.status == "active"
assert sub.seat_limit == 3
@pytest.mark.asyncio
async def test_apply_event_duplicate_event_id_skips(
test_db, test_user,
):
"""Two successful invocations with the same event_id must not double-apply.
Second call returns False; mutations are not repeated."""
from app.services.billing import BillingService
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
test_db.add(Subscription(
account_id=account_id, plan="pro", status="trialing",
stripe_subscription_id="sub_dup",
))
await test_db.commit()
event_id = "evt_dedupe_1"
payload = {"data": {"object": {
"id": "sub_dup",
"status": "active",
"current_period_start": 1714521600,
"current_period_end": 1717113600,
"items": {"data": [{"quantity": 7}]},
"cancel_at_period_end": False,
}}}
applied1 = await BillingService.apply_subscription_event(
test_db,
event_id=event_id,
event_type="customer.subscription.updated",
payload=payload,
)
assert applied1 is True
sub = (await test_db.execute(
select(Subscription).where(Subscription.account_id == account_id)
)).scalar_one()
assert sub.status == "active"
assert sub.seat_limit == 7
# Mutate locally so we can prove the second call doesn't re-run the handler.
sub.seat_limit = 99
await test_db.commit()
applied2 = await BillingService.apply_subscription_event(
test_db,
event_id=event_id,
event_type="customer.subscription.updated",
payload=payload,
)
assert applied2 is False
await test_db.refresh(sub)
# Handler did NOT run again — our local mutation is preserved.
assert sub.seat_limit == 99

View File

@@ -40,11 +40,16 @@ services:
- ALGORITHM=HS256
- ACCESS_TOKEN_EXPIRE_MINUTES=15
- REFRESH_TOKEN_EXPIRE_DAYS=7
- REQUIRE_INVITE_CODE=true
- REQUIRE_INVITE_CODE=false
- FEEDBACK_EMAIL=feedback@resolutionflow.com
- AI_PROVIDER=anthropic
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY:-}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
- STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
- SELF_SERVE_ENABLED=${SELF_SERVE_ENABLED:-false}
- INTERNAL_TESTER_EMAILS=${INTERNAL_TESTER_EMAILS:-}
- ENABLE_MCP_MICROSOFT_LEARN=true
- FRONTEND_URL=http://docker-01:5173
- CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173","http://docker-01:5173","http://100.64.78.44:5173"]

View File

@@ -21,3 +21,8 @@ VITE_OAUTH_REDIRECT_BASE=
# Self-serve signup safety fallback used by useAppConfig when GET /config/public
# is unreachable. Authoritative value comes from backend SELF_SERVE_ENABLED.
VITE_SELF_SERVE_ENABLED=false
# Calendly link surfaced on the /contact-sales confirmation screen. When unset,
# the "Want to skip ahead?" block is hidden. Vite bakes at build time, so prod
# requires ARG+ENV in frontend/Dockerfile.
VITE_CALENDLY_URL=

View File

@@ -22,6 +22,7 @@ ARG VITE_GOOGLE_CLIENT_ID
ARG VITE_MS_CLIENT_ID
ARG VITE_OAUTH_REDIRECT_BASE
ARG VITE_SELF_SERVE_ENABLED
ARG VITE_CALENDLY_URL
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY
@@ -31,6 +32,7 @@ ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
ENV VITE_MS_CLIENT_ID=$VITE_MS_CLIENT_ID
ENV VITE_OAUTH_REDIRECT_BASE=$VITE_OAUTH_REDIRECT_BASE
ENV VITE_SELF_SERVE_ENABLED=$VITE_SELF_SERVE_ENABLED
ENV VITE_CALENDLY_URL=$VITE_CALENDLY_URL
# Build the application
RUN npm run build

View File

@@ -1,5 +1,15 @@
import { AxiosError } from 'axios'
import apiClient from './client'
import type { BillingStateApiResponse, BillingStatePayload } from '@/types'
import {
BillingPortalError,
type BillingPortalErrorCode,
type BillingPortalSessionResponse,
type BillingStateApiResponse,
type BillingStatePayload,
type CheckoutSessionRequest,
type CheckoutSessionResponse,
} from '@/types/billing'
/**
* Single boundary where the snake_case backend payload is transformed
@@ -22,6 +32,48 @@ export const billingApi = {
const response = await apiClient.get<BillingStateApiResponse>('/billing/state')
return transformBillingState(response.data)
},
/**
* Request a Stripe Customer Portal session URL for the active account.
*
* Throws a typed `BillingPortalError` when:
* - HTTP 503 → `stripe_not_configured` (server-side Stripe is disabled)
* - HTTP 400 + `error: 'no_stripe_customer'` → account hasn't been billed yet
*
* Other errors (5xx, network) propagate as the underlying AxiosError.
*/
async getPortalSession(): Promise<BillingPortalSessionResponse> {
try {
const response = await apiClient.get<BillingPortalSessionResponse>(
'/billing/portal-session',
)
return response.data
} catch (err) {
if (err instanceof AxiosError && err.response) {
const { status, data } = err.response
const code: BillingPortalErrorCode | null =
status === 503
? 'stripe_not_configured'
: status === 400 && data?.detail?.error === 'no_stripe_customer'
? 'no_stripe_customer'
: null
if (code) {
throw new BillingPortalError(code)
}
}
throw err
}
},
async createCheckoutSession(
payload: CheckoutSessionRequest,
): Promise<CheckoutSessionResponse> {
const response = await apiClient.post<CheckoutSessionResponse>(
'/billing/checkout-session',
payload,
)
return response.data
},
}
export default billingApi

View File

@@ -10,6 +10,14 @@ export { default as stepsApi } from './steps'
export { default as stepCategoriesApi } from './stepCategories'
export { default as accountsApi } from './accounts'
export { default as billingApi } from './billing'
export { default as plansApi } from './plans'
export type { PublicPlanResponse } from './plans'
export { default as salesApi } from './sales'
export type {
SalesLeadCreatePayload,
SalesLeadCreateResponse,
SalesLeadSource,
} from './sales'
export { default as usageApi } from './usage'
export { default as adminApi } from './admin'
export { treeMarkdownApi } from './treeMarkdown'

22
frontend/src/api/plans.ts Normal file
View File

@@ -0,0 +1,22 @@
import apiClient from './client'
export interface PublicPlanResponse {
plan: string
display_name: string
description: string | null
monthly_price_cents: number | null
annual_price_cents: number | null
max_seats: number | null
sort_order: number
is_public: boolean
}
export const plansApi = {
/** Public plan catalog for the marketing /pricing page. No auth. */
async getPublic(): Promise<PublicPlanResponse[]> {
const response = await apiClient.get<PublicPlanResponse[]>('/plans/public')
return response.data
},
}
export default plansApi

32
frontend/src/api/sales.ts Normal file
View File

@@ -0,0 +1,32 @@
import apiClient from './client'
export type SalesLeadSource = 'pricing_page' | 'register_footer' | 'landing_page'
export interface SalesLeadCreatePayload {
email: string
name: string
company: string
team_size?: string
message?: string
source: SalesLeadSource
posthog_distinct_id?: string
}
export interface SalesLeadCreateResponse {
id: string
status: 'received'
}
export const salesApi = {
/**
* Public Talk-to-Sales submission. No auth required. Rate-limited per IP
* server-side (5/hour). Server emits PostHog `talk_to_sales_form_submitted`
* — frontend should NOT also fire this event.
*/
async createLead(payload: SalesLeadCreatePayload): Promise<SalesLeadCreateResponse> {
const response = await apiClient.post<SalesLeadCreateResponse>('/sales-leads', payload)
return response.data
},
}
export default salesApi

View File

@@ -8,6 +8,7 @@ interface PageMetaProps {
}
const SITE_NAME = 'ResolutionFlow'
const DEFAULT_TAGLINE = 'AI-Powered Troubleshooting for MSPs'
const DEFAULT_DESCRIPTION = 'Transform troubleshooting into guided workflows with automatic documentation'
/**
@@ -20,7 +21,7 @@ export function PageMeta({
ogImage,
ogType = 'website',
}: PageMetaProps) {
const fullTitle = title ? `${title} | ${SITE_NAME}` : `${SITE_NAME} - Decision Tree Platform`
const fullTitle = title ? `${title} | ${SITE_NAME}` : `${SITE_NAME} ${DEFAULT_TAGLINE}`
return (
<Helmet>

View File

@@ -49,6 +49,7 @@ const PLAN_GATE_STAGES: ReadonlyArray<TrialBannerStage> = [
* Pure helper — picks the highest-priority incomplete item, or `null` when
* all relevant items are done. Exported for direct unit testing.
*/
// eslint-disable-next-line react-refresh/only-export-components -- pure helper exported for focused unit tests
export function pickNextStep(
status: OnboardingStatus,
trialStage: TrialBannerStage | null,

View File

@@ -29,6 +29,7 @@ const PLAN_GATE_STAGES: ReadonlyArray<TrialBannerStage> = [
'expired',
]
// eslint-disable-next-line react-refresh/only-export-components -- pure helper exported for focused unit tests
export function buildChecklistItems(
status: OnboardingStatus,
trialStage: TrialBannerStage | null,

View File

@@ -1,12 +1,12 @@
import { cn } from '@/lib/utils'
interface CheckoutButtonProps {
plan: 'pro' | 'team'
plan: 'starter' | 'pro' | 'enterprise'
className?: string
}
export function CheckoutButton({ plan, className }: CheckoutButtonProps) {
const planLabels = { pro: 'Pro', team: 'Team' }
const planLabels = { starter: 'Starter', pro: 'Pro', enterprise: 'Enterprise' }
return (
<button

View File

@@ -66,10 +66,7 @@ export function useAppConfig(): UseAppConfigResult {
const [config, setConfig] = useState<PublicConfig | null>(cached)
useEffect(() => {
if (cached) {
setConfig(cached)
return
}
if (cached) return
let active = true
const handler = (c: PublicConfig) => {
if (active) setConfig(c)

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useBillingStore } from '@/store/billingStore'
import { usageApi } from '@/api/usage'
@@ -53,61 +53,38 @@ function coerceLimit(raw: unknown): number | null {
export function useFeatureLimit(field: string): FeatureLimitResult {
const limit = useBillingStore((state) => coerceLimit(state.planLimits[field]))
// Initialize from cache on first mount only; subsequent `field` changes
// are handled inside the effect below so the render-phase result reflects
// the new field synchronously (no stale `used`/`isLoading` for one tick).
const initialCached = useRef<CacheEntry | undefined>(undefined)
if (initialCached.current === undefined) {
initialCached.current = cache.get(field)
}
const initialFresh =
initialCached.current && Date.now() - initialCached.current.timestamp < CACHE_TTL_MS
const [used, setUsed] = useState<number>(initialFresh ? initialCached.current!.used : 0)
const [isLoading, setIsLoading] = useState<boolean>(!initialFresh)
// Track the field that the current `used`/`isLoading` state describes.
// When `field` changes, we synchronously reset state in render so callers
// never see stale data for the previous field.
const stateField = useRef<string>(field)
if (stateField.current !== field) {
stateField.current = field
const [state, setState] = useState(() => {
const existing = cache.get(field)
const freshNow = existing && Date.now() - existing.timestamp < CACHE_TTL_MS
if (freshNow) {
setUsed(existing!.used)
setIsLoading(false)
} else {
setUsed(0)
setIsLoading(true)
const fresh = existing && Date.now() - existing.timestamp < CACHE_TTL_MS
return {
field,
used: fresh ? existing.used : 0,
isLoading: !fresh,
}
}
})
useEffect(() => {
const existing = cache.get(field)
if (existing && Date.now() - existing.timestamp < CACHE_TTL_MS) {
setUsed(existing.used)
setIsLoading(false)
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync local hook state with fresh module cache on field change
setState({ field, used: existing.used, isLoading: false })
return
}
let cancelled = false
setIsLoading(true)
setState({ field, used: 0, isLoading: true })
usageApi
.getCount(field)
.then((result) => {
if (cancelled) return
cache.set(field, { used: result.used, timestamp: Date.now() })
setUsed(result.used)
setState({ field, used: result.used, isLoading: false })
})
.catch(() => {
// TODO: backend /usage/{field} endpoint not yet implemented (planned).
// 404s and other errors degrade to used=0 silently — no toast.
if (cancelled) return
setUsed(0)
})
.finally(() => {
if (cancelled) return
setIsLoading(false)
setState({ field, used: 0, isLoading: false })
})
return () => {
@@ -115,6 +92,8 @@ export function useFeatureLimit(field: string): FeatureLimitResult {
}
}, [field])
const used = state.field === field ? state.used : 0
const isLoading = state.field === field ? state.isLoading : true
const percentage =
limit === null || limit <= 0 ? null : Math.min(100, Math.round((used / limit) * 100))
const isAtLimit = limit !== null && used >= limit

View File

@@ -8,7 +8,7 @@ export function useSubscription() {
const usage = subscription?.usage ?? null
const isActive = subscription?.subscription.status === 'active' || subscription?.subscription.status === 'trialing'
const isPaidPlan = plan === 'pro' || plan === 'team'
const isPaidPlan = plan === 'pro' || plan === 'starter' || plan === 'enterprise'
const canUseFeature = (feature: 'custom_branding' | 'priority_support'): boolean => {
if (!limits) return false

View File

@@ -1,3 +1,4 @@
import { useState } from 'react'
import { useBillingStore } from '@/store/billingStore'
export type TrialBannerStage =
@@ -28,6 +29,7 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000
*/
export function useTrialBanner(): TrialBannerResult {
const subscription = useBillingStore((state) => state.subscription)
const [now] = useState(() => Date.now())
if (!subscription) {
return { stage: null, daysRemaining: null }
@@ -51,7 +53,6 @@ export function useTrialBanner(): TrialBannerResult {
// upgrade prompt still surfaces rather than silently swallowing it.
return { stage: 'expired', daysRemaining: null }
}
const now = Date.now()
if (end <= now) {
return { stage: 'expired', daysRemaining: 0 }
}

View File

@@ -47,6 +47,7 @@ export function AcceptInvitePage() {
useEffect(() => {
if (!code) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- route changes without a code should replace stale lookup state
setLookup({ status: 'missing-code' })
return
}

View File

@@ -6,6 +6,7 @@ import {
Check,
Clock,
Copy,
CreditCard,
Crown,
FolderTree,
Loader2,
@@ -417,10 +418,10 @@ export function AccountSettingsPage() {
<p className="text-sm text-muted-foreground">Plan limits unavailable.</p>
)}
{plan !== 'team' && (
{plan !== 'enterprise' && (
<div className="flex flex-wrap justify-end gap-2 pt-2">
{plan === 'free' && <CheckoutButton plan="pro" />}
<CheckoutButton plan="team" />
<CheckoutButton plan="enterprise" />
</div>
)}
</section>
@@ -598,6 +599,12 @@ export function AccountSettingsPage() {
title="Profile"
description="Your name, email, and personal preferences"
/>
<SettingsRow
to="/account/billing"
icon={<CreditCard className="h-4 w-4" />}
title="Billing"
description="Subscription, payment method, and invoices"
/>
</div>
{isAccountOwner && (

View File

@@ -0,0 +1,396 @@
import { useMemo, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { salesApi, type SalesLeadSource } from '@/api/sales'
import { PageMeta } from '@/components/common/PageMeta'
import { useAppConfig } from '@/hooks/useAppConfig'
import '@/styles/landing.css'
/* ---------------------------------------------------------------------------
* Source detection
*
* The backend `/sales-leads` endpoint requires a `source` enum. We classify
* by document.referrer: visitors landing on /contact-sales after viewing the
* pricing page get tagged `pricing_page`; everything else is `landing_page`.
* `register_footer` is reserved for the (future) sign-up footer CTA.
*
* Acceptable for v1 — server-side PostHog event uses this same `source` value.
* ------------------------------------------------------------------------- */
function detectSource(): SalesLeadSource {
if (typeof document === 'undefined') return 'landing_page'
const ref = document.referrer || ''
if (ref.includes('/pricing')) return 'pricing_page'
return 'landing_page'
}
const TEAM_SIZE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: '', label: 'Select team size' },
{ value: '1-2', label: '12' },
{ value: '3-5', label: '35' },
{ value: '6-10', label: '610' },
{ value: '11-25', label: '1125' },
{ value: '26+', label: 'More than 26' },
]
interface FormState {
name: string
email: string
company: string
team_size: string
message: string
}
const INITIAL: FormState = {
name: '',
email: '',
company: '',
team_size: '',
message: '',
}
function ContactSalesNotFound() {
return (
<div
data-testid="contact-sales-not-found"
style={{
minHeight: '60vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
padding: '2rem',
}}
>
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>Page not found</h1>
<p style={{ color: '#9198a8' }}>This page is not available.</p>
<Link to="/login" style={{ color: '#60a5fa', marginTop: '1rem' }}>
Go to login
</Link>
</div>
)
}
export function ContactSalesPage() {
const appConfig = useAppConfig()
const [form, setForm] = useState<FormState>(INITIAL)
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [error, setError] = useState<string | null>(null)
const calendlyUrl = useMemo(() => {
const raw = import.meta.env.VITE_CALENDLY_URL
return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : ''
}, [])
// Self-serve disabled: 404. (Same pattern as PricingPage — done after hooks.)
if (!appConfig.isLoading && !appConfig.self_serve_enabled) {
return (
<>
<PageMeta title="Page not found" />
<ContactSalesNotFound />
</>
)
}
const handleChange =
(field: keyof FormState) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }))
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (submitting) return
const name = form.name.trim()
const email = form.email.trim()
const company = form.company.trim()
if (!name || !email || !company) {
setError('Please fill in name, work email, and company.')
return
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError('Enter a valid work email address.')
return
}
setSubmitting(true)
setError(null)
try {
await salesApi.createLead({
name,
email,
company,
team_size: form.team_size || undefined,
message: form.message.trim() || undefined,
source: detectSource(),
})
setSubmitted(true)
} catch {
// The backend may rate-limit (429) or reject for validation; surface a
// generic message and allow retry. Don't leak internal errors.
setError('Something went wrong. Please try again or email hello@resolutionflow.com.')
} finally {
setSubmitting(false)
}
}
return (
<div className="landing-page">
<PageMeta
title="Talk to Sales"
description="Get in touch with the ResolutionFlow team about Enterprise plans, custom seats, SSO, and onboarding for your MSP."
/>
<main
className="landing-main"
style={{ paddingTop: '4rem', paddingBottom: '4rem' }}
>
<section
style={{
maxWidth: '640px',
margin: '0 auto',
padding: '3rem 1.5rem 1.5rem',
textAlign: 'center',
}}
>
<h1
style={{
color: 'var(--lp-text-heading)',
fontSize: 'clamp(1.75rem, 3.5vw, 2.5rem)',
fontWeight: 700,
lineHeight: 1.15,
margin: '0 0 0.75rem',
}}
>
Talk to Sales
</h1>
<p
style={{
color: 'var(--lp-text-body)',
fontSize: '1.05rem',
margin: 0,
}}
>
Tell us about your MSP. We&rsquo;ll reach out within 1 business day.
</p>
</section>
<section
style={{
maxWidth: '560px',
margin: '0 auto',
padding: '1rem 1.5rem 3rem',
}}
>
{submitted ? (
<div
data-testid="contact-sales-confirmation"
style={{
background: 'var(--lp-card)',
border: '1px solid var(--lp-border)',
borderRadius: '12px',
padding: '2rem 1.75rem',
textAlign: 'center',
}}
>
<h2
style={{
color: 'var(--lp-text-heading)',
fontSize: '1.25rem',
fontWeight: 600,
margin: '0 0 0.75rem',
}}
>
Thanks &mdash; we&rsquo;ll reach out within 1 business day.
</h2>
{calendlyUrl && (
<div data-testid="calendly-block">
<p style={{ color: 'var(--lp-text-body)', margin: '0 0 1rem' }}>
Want to skip ahead?
</p>
<a
data-testid="calendly-link"
href={calendlyUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-block',
padding: '0.65rem 1.25rem',
background: 'var(--lp-accent)',
color: '#0d0f15',
borderRadius: '8px',
fontWeight: 600,
textDecoration: 'none',
}}
>
Book a time
</a>
</div>
)}
</div>
) : (
<form
data-testid="contact-sales-form"
onSubmit={handleSubmit}
noValidate
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
background: 'var(--lp-card)',
border: '1px solid var(--lp-border)',
borderRadius: '12px',
padding: '1.75rem',
}}
>
<Field label="Name" htmlFor="cs-name" required>
<input
id="cs-name"
data-testid="cs-name"
type="text"
required
value={form.name}
onChange={handleChange('name')}
autoComplete="name"
style={inputStyle}
/>
</Field>
<Field label="Work email" htmlFor="cs-email" required>
<input
id="cs-email"
data-testid="cs-email"
type="email"
required
value={form.email}
onChange={handleChange('email')}
autoComplete="email"
style={inputStyle}
/>
</Field>
<Field label="Company" htmlFor="cs-company" required>
<input
id="cs-company"
data-testid="cs-company"
type="text"
required
value={form.company}
onChange={handleChange('company')}
autoComplete="organization"
style={inputStyle}
/>
</Field>
<Field label="Team size" htmlFor="cs-team-size">
<select
id="cs-team-size"
data-testid="cs-team-size"
value={form.team_size}
onChange={handleChange('team_size')}
style={inputStyle}
>
{TEAM_SIZE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</Field>
<Field label="What brought you here?" htmlFor="cs-message">
<textarea
id="cs-message"
data-testid="cs-message"
rows={4}
value={form.message}
onChange={handleChange('message')}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</Field>
{error && (
<div
role="alert"
data-testid="cs-error"
style={{ color: '#f87171', fontSize: '0.9rem' }}
>
{error}
</div>
)}
<button
type="submit"
data-testid="cs-submit"
disabled={submitting}
style={{
padding: '0.75rem 1.25rem',
background: 'var(--lp-accent)',
color: '#0d0f15',
border: 'none',
borderRadius: '8px',
fontWeight: 600,
cursor: submitting ? 'not-allowed' : 'pointer',
opacity: submitting ? 0.7 : 1,
}}
>
{submitting ? 'Sending…' : 'Submit'}
</button>
</form>
)}
</section>
</main>
</div>
)
}
function Field({
label,
htmlFor,
required,
children,
}: {
label: string
htmlFor: string
required?: boolean
children: React.ReactNode
}) {
return (
<label
htmlFor={htmlFor}
style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}
>
<span
style={{
color: 'var(--lp-text-heading)',
fontSize: '0.9rem',
fontWeight: 500,
}}
>
{label}
{required && (
<span aria-hidden="true" style={{ color: 'var(--lp-accent)', marginLeft: '0.25rem' }}>
*
</span>
)}
</span>
{children}
</label>
)
}
const inputStyle: React.CSSProperties = {
padding: '0.6rem 0.75rem',
background: 'var(--lp-bg-alt, #1a1d26)',
border: '1px solid var(--lp-border)',
borderRadius: '8px',
color: 'var(--lp-text-heading)',
fontSize: '0.95rem',
fontFamily: 'inherit',
outline: 'none',
}
export default ContactSalesPage

View File

@@ -1,10 +1,9 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
import { useAppConfig } from '@/hooks/useAppConfig'
import '@/styles/landing.css'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const FAQ_ITEMS = [
{
q: 'How is this different from just using ChatGPT?',
@@ -16,7 +15,7 @@ const FAQ_ITEMS = [
},
{
q: 'What PSA tools do you integrate with?',
a: 'Launching with ConnectWise PSA \u2014 session documentation exports directly as internal ticket notes. Atera and Syncro integrations are next. During beta, you can copy formatted notes into any PSA.',
a: 'Launching with ConnectWise PSA session documentation exports directly as internal ticket notes. Atera and Syncro integrations are next. During beta, you can copy formatted notes into any PSA.',
},
{
q: 'What counts as a \u201csession\u201d?',
@@ -24,16 +23,14 @@ const FAQ_ITEMS = [
},
{
q: 'What if FlowPilot gets it wrong?',
a: 'FlowPilot is a copilot, not autopilot. Every suggestion is a recommendation \u2014 you decide what to act on. And because every step is documented, you always have a full audit trail of what was tried and why.',
a: 'FlowPilot is a copilot, not autopilot. Every suggestion is a recommendation you decide what to act on. And because every step is documented, you always have a full audit trail of what was tried and why.',
},
]
export default function LandingPage() {
const appConfig = useAppConfig()
const [navScrolled, setNavScrolled] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [betaEmail, setBetaEmail] = useState('')
const [betaStatus, setBetaStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
const [betaError, setBetaError] = useState('')
const [openFaq, setOpenFaq] = useState<number | null>(null)
const mobileMenuRef = useRef<HTMLDivElement>(null)
@@ -71,32 +68,6 @@ export default function LandingPage() {
return () => observer.disconnect()
}, [])
const handleBetaSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
const trimmed = betaEmail.trim()
if (!trimmed || betaStatus === 'sending') return
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
setBetaStatus('error')
setBetaError('Enter a valid email address.')
return
}
setBetaStatus('sending')
setBetaError('')
try {
const resp = await fetch(`${API_URL}/api/v1/beta-signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: trimmed }),
})
if (!resp.ok) throw new Error('Signup failed')
setBetaStatus('sent')
setBetaEmail('')
} catch {
setBetaStatus('error')
setBetaError('Could not complete signup. Please try again or email hello@resolutionflow.com.')
}
}, [betaEmail, betaStatus])
const toggleFaq = (index: number) => {
setOpenFaq(prev => prev === index ? null : index)
}
@@ -104,8 +75,8 @@ export default function LandingPage() {
return (
<>
<PageMeta
title="ResolutionFlow \u2014 From Issue to Resolution, Documented"
description="Your AI troubleshooting copilot. Describe the issue, get help fixing it, and get clean ticket notes \u2014 automatically."
title="ResolutionFlow From Issue to Resolution, Documented"
description="Your AI troubleshooting copilot. Describe the issue, get help fixing it, and get clean ticket notes automatically."
/>
<div className="landing-page">
@@ -174,6 +145,15 @@ export default function LandingPage() {
</p>
<div className="landing-hero-actions">
<Link to="/register" className="landing-btn-hero-primary">Start Free</Link>
{appConfig.self_serve_enabled && (
<Link
to="/pricing"
className="landing-btn-hero-secondary"
data-testid="landing-see-pricing"
>
See pricing
</Link>
)}
<a href="#how-it-works" className="landing-btn-hero-secondary">See How It Works</a>
</div>
<p className="landing-hero-credibility">
@@ -422,34 +402,10 @@ export default function LandingPage() {
<section className="landing-cta-section landing-reveal">
<div className="landing-cta-inner">
<h2>Ready to stop writing ticket notes?</h2>
<p>Join the beta. Troubleshoot your next ticket with FlowPilot.</p>
<form className="landing-cta-email-form" onSubmit={handleBetaSubmit} noValidate>
<div className="landing-cta-input-wrap">
<input
type="email"
className="landing-cta-email-input"
placeholder="you@yourmsp.com"
value={betaEmail}
onChange={e => {
setBetaEmail(e.target.value)
if (betaStatus === 'error') { setBetaStatus('idle'); setBetaError('') }
}}
required
aria-describedby="beta-status"
/>
<button type="submit" className="landing-btn-hero-primary" disabled={betaStatus === 'sending'}>
{betaStatus === 'sending' ? 'Joining\u2026' : betaStatus === 'sent' ? 'Joined!' : 'Join Beta'}
</button>
</div>
<div id="beta-status" aria-live="polite" className="landing-cta-status">
{betaStatus === 'sent' && (
<p className="landing-cta-success">You&apos;re in. We&apos;ll send beta access details soon.</p>
)}
{betaStatus === 'error' && betaError && (
<p className="landing-cta-error">{betaError}</p>
)}
</div>
</form>
<p>Get early access. Troubleshoot your next ticket with FlowPilot.</p>
<div className="landing-cta-actions">
<Link to="/register?from=beta" className="landing-btn-hero-primary">Get started</Link>
</div>
<p className="landing-cta-fine-print">Free to start. No credit card required.</p>
</div>
</section>

View File

@@ -58,6 +58,7 @@ export function OAuthCallbackPage() {
}
if (oauthError) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- callback URL validation maps directly into local error state
setError(`OAuth error: ${oauthError}`)
return
}

View File

@@ -0,0 +1,439 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { plansApi, type PublicPlanResponse } from '@/api/plans'
import { PageMeta } from '@/components/common/PageMeta'
import { useAppConfig } from '@/hooks/useAppConfig'
import '@/styles/landing.css'
/* ---------------------------------------------------------------------------
* v1 hardcoded comparison table
*
* The marketing /pricing page surfaces a small "what's in each plan" table.
* Long-term, the source of truth for "plan X has feature Y" should be a
* server-side feature-flag mapping (likely keyed off feature_flag.display_name
* + plan_features). For v1 we hardcode the well-known features so we can ship
* the page without a backend dependency. Replace this block when a server-side
* feature mapping endpoint exists.
* ------------------------------------------------------------------------- */
type PlanColumn = 'starter' | 'pro' | 'enterprise'
const COMPARISON_ROWS: Array<{
feature: string
values: Record<PlanColumn, boolean>
}> = [
{ feature: 'PSA Integration', values: { starter: false, pro: true, enterprise: true } },
{ feature: 'KB Accelerator', values: { starter: false, pro: true, enterprise: true } },
{ feature: 'AI Builder', values: { starter: true, pro: true, enterprise: true } },
{ feature: 'Custom Branding', values: { starter: false, pro: false, enterprise: true } },
{ feature: 'Priority Support', values: { starter: false, pro: true, enterprise: true } },
]
function formatPrice(cents: number | null | undefined): string {
if (cents == null) return ''
const dollars = cents / 100
// Whole dollars (no decimals) for marketing display.
return `$${Math.round(dollars).toLocaleString()}`
}
function PricingNotFound() {
return (
<div
data-testid="pricing-not-found"
style={{
minHeight: '60vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
padding: '2rem',
}}
>
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>Page not found</h1>
<p style={{ color: '#9198a8' }}>This page is not available.</p>
<Link to="/login" style={{ color: '#60a5fa', marginTop: '1rem' }}>
Go to login
</Link>
</div>
)
}
interface PlanCardProps {
plan: PublicPlanResponse | null
fallback: {
plan: string
display_name: string
description: string
}
recommended?: boolean
hidePrice?: boolean
ctaLabel: string
ctaHref: string
ctaTestId: string
}
function PlanCard({ plan, fallback, recommended, hidePrice, ctaLabel, ctaHref, ctaTestId }: PlanCardProps) {
const displayName = plan?.display_name ?? fallback.display_name
const description = plan?.description ?? fallback.description
const monthlyCents = plan?.monthly_price_cents ?? null
return (
<div
data-testid={`plan-card-${fallback.plan}`}
style={{
position: 'relative',
background: 'var(--lp-card)',
border: recommended
? '2px solid var(--lp-accent)'
: '1px solid var(--lp-border)',
borderRadius: '12px',
padding: '2rem 1.75rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
{recommended && (
<span
data-testid="recommended-badge"
style={{
position: 'absolute',
top: '-12px',
left: '50%',
transform: 'translateX(-50%)',
padding: '4px 12px',
background: 'var(--lp-accent)',
color: '#0d0f15',
borderRadius: '999px',
fontSize: '0.75rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
Recommended
</span>
)}
<div>
<h3
style={{
color: 'var(--lp-text-heading)',
fontSize: '1.25rem',
fontWeight: 600,
margin: 0,
}}
>
{displayName}
</h3>
<p style={{ margin: '0.5rem 0 0', color: 'var(--lp-text-secondary)', fontSize: '0.9rem' }}>
{description}
</p>
</div>
<div style={{ minHeight: '3rem' }}>
{hidePrice ? (
<div style={{ color: 'var(--lp-text-heading)', fontSize: '1.25rem', fontWeight: 600 }}>
Custom pricing
</div>
) : monthlyCents != null ? (
<div>
<span style={{ color: 'var(--lp-text-heading)', fontSize: '2.25rem', fontWeight: 700 }}>
{formatPrice(monthlyCents)}
</span>
<span style={{ color: 'var(--lp-text-secondary)', marginLeft: '0.35rem' }}>/ month</span>
</div>
) : (
<div style={{ color: 'var(--lp-text-secondary)' }}>Contact us</div>
)}
</div>
<Link
to={ctaHref}
data-testid={ctaTestId}
style={{
display: 'inline-block',
textAlign: 'center',
padding: '0.75rem 1.25rem',
background: recommended ? 'var(--lp-accent)' : 'transparent',
color: recommended ? '#0d0f15' : 'var(--lp-text-heading)',
border: recommended ? 'none' : '1px solid var(--lp-border-hover)',
borderRadius: '8px',
fontWeight: 600,
textDecoration: 'none',
}}
>
{ctaLabel}
</Link>
</div>
)
}
export function PricingPage() {
const appConfig = useAppConfig()
const [plans, setPlans] = useState<PublicPlanResponse[] | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Fetch plans on mount when self-serve is enabled.
useEffect(() => {
if (appConfig.isLoading) return
if (!appConfig.self_serve_enabled) return
let cancelled = false
plansApi
.getPublic()
.then((data) => {
if (cancelled) return
setPlans(data)
setError(null)
})
.catch(() => {
if (cancelled) return
// Non-fatal: page still renders with fallback descriptions and no
// server-driven prices. The CTA still works via /register?plan=...
setError('Unable to load live pricing.')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [appConfig.isLoading, appConfig.self_serve_enabled])
// Self-serve disabled: render a 404-style fallback. Done after hooks so
// the React rules-of-hooks invariant holds.
if (!appConfig.isLoading && !appConfig.self_serve_enabled) {
return (
<>
<PageMeta title="Page not found" />
<PricingNotFound />
</>
)
}
const planByName = (name: string) =>
plans?.find((p) => p.plan.toLowerCase() === name) ?? null
return (
<div className="landing-page">
<PageMeta
title="Pricing"
description="ResolutionFlow plans for MSPs — Starter, Pro, and Enterprise. Try Pro free for 14 days, no credit card required."
/>
<main className="landing-main" style={{ paddingTop: '4rem', paddingBottom: '4rem' }}>
{/* ---- HERO ---- */}
<section
style={{
maxWidth: '720px',
margin: '0 auto',
padding: '4rem 1.5rem 2rem',
textAlign: 'center',
}}
>
<h1
style={{
color: 'var(--lp-text-heading)',
fontSize: 'clamp(2rem, 4vw, 2.75rem)',
fontWeight: 700,
lineHeight: 1.15,
margin: '0 0 1rem',
}}
>
Simple pricing for MSPs of every size
</h1>
<p
data-testid="hero-trial-line"
style={{
color: 'var(--lp-text-body)',
fontSize: '1.125rem',
margin: 0,
}}
>
Try Pro free for 14 days. No credit card required.
</p>
</section>
{/* ---- PLAN CARDS ---- */}
<section
aria-label="Plans"
style={{
maxWidth: '1100px',
margin: '0 auto',
padding: '2rem 1.5rem',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
gap: '1.5rem',
}}
>
<PlanCard
plan={planByName('starter')}
fallback={{
plan: 'starter',
display_name: 'Starter',
description: 'For solo techs getting structured.',
}}
ctaLabel="Start free trial"
ctaHref="/register?plan=starter"
ctaTestId="cta-starter"
/>
<PlanCard
plan={planByName('pro')}
recommended
fallback={{
plan: 'pro',
display_name: 'Pro',
description: 'For growing MSP teams. PSA integration + KB Accelerator.',
}}
ctaLabel="Start free trial"
ctaHref="/register?plan=pro"
ctaTestId="cta-pro"
/>
<PlanCard
plan={planByName('enterprise')}
hidePrice
fallback={{
plan: 'enterprise',
display_name: 'Enterprise',
description: 'Custom branding, custom seats, and a dedicated success contact.',
}}
ctaLabel="Talk to sales"
ctaHref="/contact-sales"
ctaTestId="cta-enterprise"
/>
</section>
{loading && (
<div
aria-live="polite"
style={{ textAlign: 'center', color: 'var(--lp-text-secondary)' }}
>
Loading pricing
</div>
)}
{error && (
<div
role="status"
style={{ textAlign: 'center', color: 'var(--lp-text-secondary)', marginTop: '0.5rem' }}
>
{error}
</div>
)}
{/* ---- COMPARISON TABLE ---- */}
<section
aria-label="Plan comparison"
style={{
maxWidth: '1000px',
margin: '3rem auto 2rem',
padding: '0 1.5rem',
}}
>
<h2
style={{
color: 'var(--lp-text-heading)',
fontSize: '1.5rem',
fontWeight: 600,
margin: '0 0 1rem',
textAlign: 'center',
}}
>
Compare plans
</h2>
<div
style={{
overflowX: 'auto',
border: '1px solid var(--lp-border)',
borderRadius: '12px',
}}
>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
color: 'var(--lp-text-body)',
}}
>
<thead>
<tr style={{ background: 'var(--lp-bg-alt)' }}>
<th style={{ textAlign: 'left', padding: '0.75rem 1rem', fontWeight: 600 }}>
Feature
</th>
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Starter</th>
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Pro</th>
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Enterprise</th>
</tr>
</thead>
<tbody>
{COMPARISON_ROWS.map((row) => (
<tr key={row.feature} style={{ borderTop: '1px solid var(--lp-border)' }}>
<td style={{ padding: '0.75rem 1rem' }}>{row.feature}</td>
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
{row.values.starter ? '✓' : '—'}
</td>
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
{row.values.pro ? '✓' : '—'}
</td>
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
{row.values.enterprise ? '✓' : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* ---- TESTIMONIAL SLOT (placeholder) ---- */}
<section
aria-label="Customer testimonial"
style={{
maxWidth: '720px',
margin: '3rem auto 2rem',
padding: '2rem 1.5rem',
background: 'var(--lp-card)',
border: '1px solid var(--lp-border)',
borderRadius: '12px',
textAlign: 'center',
}}
data-testid="testimonial-slot"
>
<blockquote
style={{
fontStyle: 'italic',
color: 'var(--lp-text-body)',
fontSize: '1.05rem',
margin: 0,
}}
>
"Pilot testimonials coming soon."
</blockquote>
<div style={{ marginTop: '0.75rem', color: 'var(--lp-text-secondary)', fontSize: '0.9rem' }}>
ResolutionFlow pilot, 2026
</div>
</section>
{/* ---- TRUST STRIP ---- */}
<section
aria-label="Trust"
data-testid="trust-strip"
style={{
maxWidth: '900px',
margin: '2rem auto 0',
padding: '1rem 1.5rem',
color: 'var(--lp-text-secondary)',
fontSize: '0.9rem',
textAlign: 'center',
}}
>
Built on Stripe + AWS · Encrypted in transit and at rest
</section>
</main>
</div>
)
}
export default PricingPage

View File

@@ -33,7 +33,8 @@ function randomState(): string {
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('')
}
/** Build provider authorize URL. Exported for tests. */
/** Build provider authorize URL. Exported for tests and invite OAuth handoff. */
// eslint-disable-next-line react-refresh/only-export-components -- pure helper shared with AcceptInvitePage and unit tests
export function buildOAuthAuthorizeUrl(
provider: 'google' | 'microsoft',
state: string,

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import { ContactSalesPage } from '../ContactSalesPage'
import { salesApi } from '@/api/sales'
import {
__resetAppConfigCache,
__setAppConfigCache,
} from '@/hooks/useAppConfig'
vi.mock('@/api/sales', () => ({
salesApi: {
createLead: vi.fn(),
},
}))
function renderPage() {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={['/contact-sales']}>
<ContactSalesPage />
</MemoryRouter>
</HelmetProvider>,
)
}
function fillRequiredFields() {
fireEvent.change(screen.getByTestId('cs-name'), { target: { value: 'Jane Doe' } })
fireEvent.change(screen.getByTestId('cs-email'), { target: { value: 'jane@acme.com' } })
fireEvent.change(screen.getByTestId('cs-company'), { target: { value: 'Acme MSP' } })
}
describe('ContactSalesPage', () => {
beforeEach(() => {
__resetAppConfigCache()
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
vi.clearAllMocks()
vi.unstubAllEnvs()
})
it('submits form and shows confirmation', async () => {
vi.stubEnv('VITE_CALENDLY_URL', 'https://calendly.com/resolutionflow/sales')
vi.mocked(salesApi.createLead).mockResolvedValue({
id: 'fake-uuid',
status: 'received',
})
renderPage()
fillRequiredFields()
fireEvent.change(screen.getByTestId('cs-team-size'), { target: { value: '11-25' } })
fireEvent.change(screen.getByTestId('cs-message'), {
target: { value: 'Looking at Enterprise pricing.' },
})
fireEvent.click(screen.getByTestId('cs-submit'))
await waitFor(() => {
expect(salesApi.createLead).toHaveBeenCalledTimes(1)
})
const payload = vi.mocked(salesApi.createLead).mock.calls[0][0]
expect(payload).toMatchObject({
name: 'Jane Doe',
email: 'jane@acme.com',
company: 'Acme MSP',
team_size: '11-25',
message: 'Looking at Enterprise pricing.',
})
// Default source is landing_page (no /pricing in referrer in jsdom).
expect(payload.source).toBe('landing_page')
// Confirmation surface replaces the form.
await waitFor(() => {
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
})
expect(screen.queryByTestId('contact-sales-form')).not.toBeInTheDocument()
expect(screen.getByText(/Thanks/i)).toBeInTheDocument()
})
it('hides Calendly section when VITE_CALENDLY_URL unset', async () => {
vi.stubEnv('VITE_CALENDLY_URL', '')
vi.mocked(salesApi.createLead).mockResolvedValue({
id: 'fake-uuid',
status: 'received',
})
renderPage()
fillRequiredFields()
fireEvent.click(screen.getByTestId('cs-submit'))
await waitFor(() => {
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
})
expect(screen.queryByTestId('calendly-block')).not.toBeInTheDocument()
expect(screen.queryByTestId('calendly-link')).not.toBeInTheDocument()
})
it('disables submit button while in flight to prevent duplicate submissions', async () => {
let resolveSubmit: (() => void) | null = null
vi.mocked(salesApi.createLead).mockImplementation(
() =>
new Promise((resolve) => {
resolveSubmit = () => resolve({ id: 'fake-uuid', status: 'received' })
}),
)
renderPage()
fillRequiredFields()
const submit = screen.getByTestId('cs-submit') as HTMLButtonElement
fireEvent.click(submit)
await waitFor(() => {
expect(submit.disabled).toBe(true)
})
// A second click while in flight should be a no-op.
fireEvent.click(submit)
expect(salesApi.createLead).toHaveBeenCalledTimes(1)
resolveSubmit?.()
await waitFor(() => {
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
})
})
it('returns 404 when self_serve_enabled is false', () => {
__resetAppConfigCache()
__setAppConfigCache({
self_serve_enabled: false,
oauth_providers: [],
})
renderPage()
expect(screen.getByTestId('contact-sales-not-found')).toBeInTheDocument()
expect(screen.queryByTestId('contact-sales-form')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import LandingPage from '../LandingPage'
import {
__resetAppConfigCache,
__setAppConfigCache,
} from '@/hooks/useAppConfig'
// jsdom does not provide IntersectionObserver. LandingPage uses it for
// scroll-reveal animations; stub a no-op so the page can mount.
beforeAll(() => {
// @ts-expect-error — test-only stub
globalThis.IntersectionObserver = class {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return []
}
}
})
function renderPage() {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={['/']}>
<LandingPage />
</MemoryRouter>
</HelmetProvider>,
)
}
describe('LandingPage', () => {
beforeEach(() => {
__resetAppConfigCache()
vi.clearAllMocks()
})
it('shows See pricing CTA when self_serve_enabled is true', async () => {
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
renderPage()
await waitFor(() => {
expect(screen.getByTestId('landing-see-pricing')).toBeInTheDocument()
})
const cta = screen.getByTestId('landing-see-pricing')
expect(cta).toHaveAttribute('href', '/pricing')
expect(cta).toHaveTextContent(/See pricing/i)
})
it('hides See pricing CTA when self_serve_enabled is false', async () => {
__setAppConfigCache({
self_serve_enabled: false,
oauth_providers: [],
})
renderPage()
// Hero "Start Free" still renders, but the gated /pricing CTA does not.
expect(screen.queryByTestId('landing-see-pricing')).not.toBeInTheDocument()
})
})

View File

@@ -13,10 +13,13 @@ vi.mock('@/api/auth', () => ({
},
}))
const mockSetTokens = vi.fn()
const mockFetchUser = vi.fn().mockResolvedValue(undefined)
vi.mock('@/store/authStore', () => ({
useAuthStore: () => ({
setTokens: vi.fn(),
fetchUser: vi.fn().mockResolvedValue(undefined),
setTokens: mockSetTokens,
fetchUser: mockFetchUser,
}),
}))
@@ -79,3 +82,40 @@ describe('OAuthCallbackPage CSRF state validation', () => {
expect(authApi.googleCallback).not.toHaveBeenCalled()
})
})
describe('OAuthCallbackPage successful callback', () => {
beforeEach(() => {
sessionStorage.clear()
vi.clearAllMocks()
localStorage.clear()
})
afterEach(() => {
sessionStorage.clear()
localStorage.clear()
})
it('persists tokens via setTokens (which marks the store authenticated) and fetches the user', async () => {
sessionStorage.setItem('rf-oauth-state', 'csrf-value')
;(authApi.googleCallback as ReturnType<typeof vi.fn>).mockResolvedValue({
access_token: 'access-123',
refresh_token: 'refresh-456',
token_type: 'bearer',
is_new_user: false,
})
renderAt('/auth/google/callback?code=auth-code-123&state=csrf-value')
await waitFor(() => {
expect(mockSetTokens).toHaveBeenCalledWith({
access_token: 'access-123',
refresh_token: 'refresh-456',
token_type: 'bearer',
})
})
expect(mockFetchUser).toHaveBeenCalled()
// Tokens are also persisted for the apiClient interceptor.
expect(localStorage.getItem('access_token')).toBe('access-123')
expect(localStorage.getItem('refresh_token')).toBe('refresh-456')
})
})

View File

@@ -0,0 +1,162 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import { PricingPage } from '../PricingPage'
import { plansApi, type PublicPlanResponse } from '@/api/plans'
import {
__resetAppConfigCache,
__setAppConfigCache,
} from '@/hooks/useAppConfig'
vi.mock('@/api/plans', () => ({
plansApi: {
getPublic: vi.fn(),
},
}))
const STARTER: PublicPlanResponse = {
plan: 'starter',
display_name: 'Starter',
description: 'For solo techs.',
monthly_price_cents: 1900,
annual_price_cents: 19000,
max_seats: 3,
sort_order: 10,
is_public: true,
}
const PRO: PublicPlanResponse = {
plan: 'pro',
display_name: 'Pro',
description: 'For growing MSP teams.',
monthly_price_cents: 4900,
annual_price_cents: 49000,
max_seats: 10,
sort_order: 20,
is_public: true,
}
const ENTERPRISE: PublicPlanResponse = {
plan: 'enterprise',
display_name: 'Enterprise',
description: 'Custom seats + branding.',
monthly_price_cents: null,
annual_price_cents: null,
max_seats: null,
sort_order: 30,
is_public: true,
}
function renderPage() {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={['/pricing']}>
<PricingPage />
</MemoryRouter>
</HelmetProvider>,
)
}
describe('PricingPage', () => {
beforeEach(() => {
__resetAppConfigCache()
vi.clearAllMocks()
})
it('shows three plan cards with prices from API', async () => {
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
renderPage()
await waitFor(() => {
expect(plansApi.getPublic).toHaveBeenCalled()
})
// Three plan cards present.
expect(screen.getByTestId('plan-card-starter')).toBeInTheDocument()
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
expect(screen.getByTestId('plan-card-enterprise')).toBeInTheDocument()
// Prices from API rendered.
await waitFor(() => {
expect(screen.getByText('$19')).toBeInTheDocument()
expect(screen.getByText('$49')).toBeInTheDocument()
})
// Enterprise card hides price (shows "Custom pricing" instead).
expect(screen.getByText(/Custom pricing/i)).toBeInTheDocument()
// Pro is recommended.
expect(screen.getByTestId('recommended-badge')).toBeInTheDocument()
})
it('Start free trial button navigates to /register?plan=pro', async () => {
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
renderPage()
const proCta = await screen.findByTestId('cta-pro')
expect(proCta).toHaveAttribute('href', '/register?plan=pro')
expect(proCta).toHaveTextContent(/Start free trial/i)
const starterCta = screen.getByTestId('cta-starter')
expect(starterCta).toHaveAttribute('href', '/register?plan=starter')
})
it('Talk to sales button navigates to /contact-sales', async () => {
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
renderPage()
const enterpriseCta = await screen.findByTestId('cta-enterprise')
expect(enterpriseCta).toHaveAttribute('href', '/contact-sales')
expect(enterpriseCta).toHaveTextContent(/Talk to sales/i)
})
it('returns 404 when self_serve_enabled is false', async () => {
__setAppConfigCache({
self_serve_enabled: false,
oauth_providers: [],
})
renderPage()
await waitFor(() => {
expect(screen.getByTestId('pricing-not-found')).toBeInTheDocument()
})
expect(screen.getByText(/Page not found/i)).toBeInTheDocument()
// No plan cards rendered, no API call made.
expect(screen.queryByTestId('plan-card-starter')).not.toBeInTheDocument()
expect(plansApi.getPublic).not.toHaveBeenCalled()
})
it('uses softer trust language (no SOC2/DPA claim yet)', async () => {
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
renderPage()
const trust = await screen.findByTestId('trust-strip')
expect(trust).toHaveTextContent(/Built on Stripe \+ AWS/i)
expect(trust).toHaveTextContent(/Encrypted in transit and at rest/i)
expect(trust).not.toHaveTextContent(/SOC ?2/i)
})
})

View File

@@ -0,0 +1,267 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { CreditCard, AlertCircle, Loader2, ExternalLink, Crown } from 'lucide-react'
import { billingApi } from '@/api/billing'
import { Button } from '@/components/ui/Button'
import { PageMeta } from '@/components/common/PageMeta'
import { useBillingStore } from '@/store/billingStore'
import { BillingPortalError } from '@/types/billing'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
function formatDate(value: string | null | undefined): string {
if (!value) return '—'
return new Date(value).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function statusLabel(status: string): string {
switch (status) {
case 'trialing':
return 'Trialing'
case 'active':
return 'Active'
case 'past_due':
return 'Past due'
case 'canceled':
return 'Canceled'
case 'incomplete':
return 'Incomplete'
case 'complimentary':
return 'Complimentary'
default:
return status
}
}
function statusToneClass(status: string): string {
switch (status) {
case 'active':
case 'complimentary':
return 'text-success'
case 'trialing':
return 'text-info'
case 'past_due':
case 'incomplete':
return 'text-warning'
case 'canceled':
return 'text-danger'
default:
return 'text-muted-foreground'
}
}
export function BillingPage() {
const subscription = useBillingStore((s) => s.subscription)
const planBilling = useBillingStore((s) => s.planBilling)
const isLoading = useBillingStore((s) => s.isLoading)
const [openingPortal, setOpeningPortal] = useState(false)
const status = subscription?.status ?? null
const isComplimentary = status === 'complimentary'
const isTrialing = status === 'trialing'
const isPastDue = status === 'past_due'
const isCanceled = status === 'canceled'
const handleOpenPortal = async () => {
setOpeningPortal(true)
try {
const { url } = await billingApi.getPortalSession()
window.location.href = url
} catch (err) {
if (err instanceof BillingPortalError) {
if (err.code === 'no_stripe_customer') {
toast.error('Complete checkout first to access billing portal.')
} else {
toast.error('Billing portal is not available right now.')
}
} else {
toast.error('Failed to open billing portal.')
}
setOpeningPortal(false)
}
}
if (isLoading && !subscription) {
return (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
return (
<>
<PageMeta title="Billing" />
<div>
{/* ── Header ─────────────────────────────────────────────────────── */}
<div className="mb-8">
<div className="flex items-center gap-3">
<CreditCard className="h-8 w-8 text-muted-foreground" />
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
Billing
</h1>
</div>
<p className="mt-2 text-muted-foreground">
Manage your subscription, payment method, and billing history.
</p>
</div>
{/* ── Past-due banner ────────────────────────────────────────────── */}
{isPastDue && (
<div
data-testid="past-due-banner"
className={cn(
'mb-6 flex flex-wrap items-start gap-3 rounded-lg border border-warning/30',
'bg-warning-dim p-4 text-foreground',
)}
>
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Your last payment failed.</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Update your payment method to keep access to ResolutionFlow.
</p>
</div>
<Button
size="sm"
loading={openingPortal}
onClick={handleOpenPortal}
data-testid="past-due-update-payment"
>
Update payment method
</Button>
</div>
)}
{/* ── Subscription summary card ──────────────────────────────────── */}
<div className="card-flat max-w-xl space-y-5 p-6">
<div className="flex items-baseline justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Crown className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">
{planBilling?.display_name ?? 'No active plan'}
</span>
</div>
{subscription && (
<div
className={cn(
'mt-1 text-xs',
statusToneClass(subscription.status),
)}
>
{statusLabel(subscription.status)}
{subscription.cancel_at_period_end && ' · cancels at period end'}
</div>
)}
</div>
{subscription?.seat_limit != null && (
<div className="text-right">
<div className="text-xs text-muted-foreground">Seats</div>
<div className="text-sm tabular-nums text-foreground">
{subscription.seat_limit}
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 gap-3 border-t border-border pt-4 sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">
{isCanceled ? 'Ends' : isTrialing ? 'Trial ends' : 'Next renewal'}
</div>
<div className="text-sm tabular-nums text-foreground">
{isComplimentary ? '—' : formatDate(subscription?.current_period_end)}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Plan started</div>
<div className="text-sm tabular-nums text-foreground">
{formatDate(subscription?.current_period_start)}
</div>
</div>
</div>
{/* State-specific messaging ------------------------------------ */}
{isComplimentary && (
<div
data-testid="complimentary-message"
className="rounded-md bg-success-dim p-3 text-xs text-success"
>
Complimentary Pro no billing required.
</div>
)}
{isTrialing && (
<div
data-testid="trial-message"
className="rounded-md bg-info-dim p-3 text-xs text-info"
>
Trial ends {formatDate(subscription?.current_period_end)} pick a plan
to continue.
</div>
)}
{isCanceled && (
<div
data-testid="canceled-message"
className="rounded-md bg-muted p-3 text-xs text-muted-foreground"
>
Subscription canceled. Reactivate by picking a plan.
</div>
)}
</div>
{/* ── Actions ────────────────────────────────────────────────────── */}
{!isComplimentary && (
<div className="mt-6 flex max-w-xl flex-wrap gap-3">
{(isTrialing || isCanceled) && (
<Link
to="/account/billing/select-plan"
data-testid="select-plan-link"
className={cn(
'inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2',
'text-sm font-semibold text-white hover:brightness-110',
)}
>
Pick a plan
</Link>
)}
{!isTrialing && !isCanceled && (
<Link
to="/account/billing/select-plan"
data-testid="change-plan-link"
className={cn(
'inline-flex items-center gap-2 rounded-lg border border-border',
'bg-input px-4 py-2 text-sm font-medium text-foreground',
'hover:border-border-hover',
)}
>
Change plan
</Link>
)}
<Button
variant="secondary"
loading={openingPortal}
onClick={handleOpenPortal}
data-testid="manage-billing-button"
>
<ExternalLink className="h-4 w-4" />
Manage billing
</Button>
</div>
)}
</div>
</>
)
}
export default BillingPage

View File

@@ -0,0 +1,354 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { Check, CreditCard, Loader2 } from 'lucide-react'
import { billingApi } from '@/api/billing'
import { plansApi, type PublicPlanResponse } from '@/api/plans'
import { Button } from '@/components/ui/Button'
import { PageMeta } from '@/components/common/PageMeta'
import { useBillingStore } from '@/store/billingStore'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { BillingInterval, CheckoutPlan } from '@/types/billing'
function formatPrice(cents: number | null | undefined): string {
if (cents == null) return ''
const dollars = cents / 100
return `$${Math.round(dollars).toLocaleString()}`
}
const PLAN_FALLBACK_FEATURES: Record<string, string[]> = {
starter: ['AI Builder', 'Up to 1 seat', 'Email support'],
pro: [
'PSA Integration',
'KB Accelerator',
'AI Builder',
'Priority support',
],
team: [
'Everything in Pro',
'Multi-seat collaboration',
'Shared categories',
],
enterprise: [
'Custom seats and SSO',
'Custom branding',
'Dedicated success contact',
],
}
interface PlanCardProps {
plan: PublicPlanResponse
interval: BillingInterval
isCurrent: boolean
isEnterprise: boolean
onSelect: (planKey: CheckoutPlan) => void
isSubmitting: boolean
}
function PlanCard({
plan,
interval,
isCurrent,
isEnterprise,
onSelect,
isSubmitting,
}: PlanCardProps) {
const planKey = plan.plan.toLowerCase() as CheckoutPlan
const cents =
interval === 'annual' ? plan.annual_price_cents : plan.monthly_price_cents
const features = PLAN_FALLBACK_FEATURES[planKey] ?? []
return (
<div
data-testid={`plan-card-${planKey}`}
className={cn(
'flex flex-col gap-4 rounded-xl border p-6',
isCurrent
? 'border-primary/40 bg-primary/5'
: 'border-border bg-card hover:border-border-hover',
)}
>
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-lg font-semibold text-foreground">
{plan.display_name}
</h3>
{isCurrent && (
<span
data-testid={`plan-current-${planKey}`}
className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
>
Current plan
</span>
)}
</div>
{plan.description && (
<p className="text-sm text-muted-foreground">{plan.description}</p>
)}
<div className="min-h-[3rem]">
{isEnterprise ? (
<div className="text-base font-medium text-foreground">
Custom pricing
</div>
) : cents != null ? (
<div>
<span className="text-3xl font-bold text-foreground">
{formatPrice(cents)}
</span>
<span className="ml-1 text-sm text-muted-foreground">
/ {interval === 'annual' ? 'year' : 'month'}
</span>
</div>
) : (
<div className="text-sm text-muted-foreground">Contact us</div>
)}
</div>
<ul className="space-y-1.5 text-sm text-muted-foreground">
{features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-success" />
<span>{feature}</span>
</li>
))}
</ul>
<div className="mt-auto pt-2">
{isEnterprise ? (
<Link
to="/contact-sales"
data-testid={`plan-cta-${planKey}`}
className={cn(
'inline-flex w-full items-center justify-center rounded-lg border border-border',
'bg-input px-4 py-2 text-sm font-medium text-foreground',
'hover:border-border-hover',
)}
>
Talk to sales
</Link>
) : (
<Button
data-testid={`plan-cta-${planKey}`}
disabled={isCurrent || isSubmitting}
loading={isSubmitting}
onClick={() => onSelect(planKey)}
className="w-full"
>
{isCurrent ? 'Current plan' : 'Continue to checkout'}
</Button>
)}
</div>
</div>
)
}
export function SelectPlanPage() {
const subscription = useBillingStore((s) => s.subscription)
const currentPlan = subscription?.plan ?? null
const isCurrentActive =
subscription?.status === 'active' || subscription?.status === 'trialing'
const [plans, setPlans] = useState<PublicPlanResponse[] | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [interval, setInterval] = useState<BillingInterval>('monthly')
const [seats, setSeats] = useState<number>(1)
const [submittingPlan, setSubmittingPlan] = useState<CheckoutPlan | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
plansApi
.getPublic()
.then((data) => {
if (cancelled) return
// Sort by sort_order so the layout is stable.
const sorted = [...data].sort((a, b) => a.sort_order - b.sort_order)
setPlans(sorted)
setLoadError(null)
})
.catch(() => {
if (cancelled) return
setLoadError('Unable to load plans. Please try again.')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
const seedSeats = useMemo(() => {
return subscription?.seat_limit && subscription.seat_limit > 0
? subscription.seat_limit
: 1
}, [subscription?.seat_limit])
useEffect(() => {
setSeats(seedSeats)
}, [seedSeats])
const handleSelectPlan = async (planKey: CheckoutPlan) => {
if (planKey === 'enterprise') return
setSubmittingPlan(planKey)
try {
const { url } = await billingApi.createCheckoutSession({
plan: planKey,
seats: Math.max(1, Math.floor(seats)),
billing_interval: interval,
})
window.location.href = url
} catch {
toast.error('Could not start checkout. Please try again.')
setSubmittingPlan(null)
}
}
return (
<>
<PageMeta title="Pick a plan" />
<div>
{/* ── Header ─────────────────────────────────────────────────────── */}
<div className="mb-8">
<div className="flex items-center gap-3">
<CreditCard className="h-8 w-8 text-muted-foreground" />
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
Pick a plan
</h1>
</div>
<p className="mt-2 text-muted-foreground">
Choose the plan that fits your team. You can change or cancel any
time.
</p>
</div>
{/* ── Controls ───────────────────────────────────────────────────── */}
<div className="mb-6 flex flex-wrap items-end gap-6">
<div>
<span className="block text-xs font-medium text-muted-foreground">
Billing interval
</span>
<div
role="tablist"
aria-label="Billing interval"
className="mt-2 inline-flex rounded-lg border border-border bg-card p-1 text-sm"
>
<button
type="button"
role="tab"
aria-selected={interval === 'monthly'}
data-testid="interval-monthly"
onClick={() => setInterval('monthly')}
className={cn(
'rounded-md px-3 py-1.5 font-medium',
interval === 'monthly'
? 'bg-primary text-white'
: 'text-muted-foreground hover:text-foreground',
)}
>
Monthly
</button>
<button
type="button"
role="tab"
aria-selected={interval === 'annual'}
data-testid="interval-annual"
onClick={() => setInterval('annual')}
className={cn(
'rounded-md px-3 py-1.5 font-medium',
interval === 'annual'
? 'bg-primary text-white'
: 'text-muted-foreground hover:text-foreground',
)}
>
Annual
</button>
</div>
</div>
<div>
<label
htmlFor="seats-input"
className="block text-xs font-medium text-muted-foreground"
>
Seats
</label>
<input
id="seats-input"
data-testid="seats-input"
type="number"
min={1}
step={1}
value={seats}
onChange={(e) => {
const next = Number.parseInt(e.target.value, 10)
if (Number.isFinite(next) && next >= 1) {
setSeats(next)
} else if (e.target.value === '') {
setSeats(1)
}
}}
className={cn(
'mt-2 w-24 rounded-lg border border-border bg-card px-3 py-1.5',
'text-sm text-foreground',
'focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20',
)}
/>
</div>
</div>
{/* ── Plan cards ─────────────────────────────────────────────────── */}
{loading && (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{loadError && !loading && (
<div className="rounded-md border border-danger/20 bg-danger-dim p-4 text-sm text-danger">
{loadError}
</div>
)}
{!loading && !loadError && plans && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{plans.map((plan) => {
const planKey = plan.plan.toLowerCase()
const isEnterprise = planKey === 'enterprise'
const isCurrent = !!(
isCurrentActive &&
currentPlan &&
currentPlan.toLowerCase() === planKey
)
return (
<PlanCard
key={plan.plan}
plan={plan}
interval={interval}
isCurrent={isCurrent}
isEnterprise={isEnterprise}
onSelect={handleSelectPlan}
isSubmitting={submittingPlan === planKey}
/>
)
})}
</div>
)}
<div className="mt-6">
<Link
to="/account/billing"
className="text-sm text-muted-foreground hover:text-foreground"
>
Back to billing
</Link>
</div>
</div>
</>
)
}
export default SelectPlanPage

View File

@@ -0,0 +1,206 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { BillingPage } from '../BillingPage'
import { useBillingStore } from '@/store/billingStore'
import { BillingPortalError } from '@/types/billing'
import type { SubscriptionState, PlanBillingState } from '@/types/billing'
vi.mock('@/api/billing', () => ({
billingApi: {
getPortalSession: vi.fn(),
},
}))
vi.mock('@/lib/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
},
}))
import { billingApi } from '@/api/billing'
import { toast } from '@/lib/toast'
const getPortalSession = billingApi.getPortalSession as unknown as ReturnType<typeof vi.fn>
const toastError = toast.error as unknown as ReturnType<typeof vi.fn>
function setBilling(opts: {
subscription: SubscriptionState | null
planBilling?: PlanBillingState | null
}) {
useBillingStore.setState({
subscription: opts.subscription,
planBilling:
opts.planBilling ??
({
display_name: 'Pro',
description: 'Pro plan',
monthly_price_cents: 4900,
annual_price_cents: 49000,
} as PlanBillingState),
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
}
function renderPage() {
return render(
<MemoryRouter>
<BillingPage />
</MemoryRouter>,
)
}
describe('BillingPage', () => {
beforeEach(() => {
getPortalSession.mockReset()
toastError.mockReset()
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
it('renders subscription summary from useBillingStore', () => {
setBilling({
subscription: {
status: 'active',
plan: 'pro',
current_period_start: '2026-04-01T00:00:00Z',
current_period_end: '2026-05-01T00:00:00Z',
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: true,
is_paid: true,
},
})
renderPage()
expect(screen.getByRole('heading', { name: 'Billing' })).toBeInTheDocument()
expect(screen.getByText('Pro')).toBeInTheDocument()
expect(screen.getByText('Active')).toBeInTheDocument()
// Seats shown
expect(screen.getByText('5')).toBeInTheDocument()
})
it('shows trial-ends message + Pick a plan CTA when trialing', () => {
setBilling({
subscription: {
status: 'trialing',
plan: 'pro',
current_period_start: '2026-04-22T00:00:00Z',
current_period_end: '2026-05-06T00:00:00Z',
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: true,
is_paid: false,
},
})
renderPage()
expect(screen.getByTestId('trial-message').textContent).toMatch(/Trial ends/)
const pickPlan = screen.getByTestId('select-plan-link')
expect(pickPlan.getAttribute('href')).toBe('/account/billing/select-plan')
})
it('shows past-due banner with update payment CTA when status=past_due', () => {
setBilling({
subscription: {
status: 'past_due',
plan: 'pro',
current_period_start: '2026-04-01T00:00:00Z',
current_period_end: '2026-05-01T00:00:00Z',
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: false,
is_paid: true,
},
})
renderPage()
expect(screen.getByTestId('past-due-banner')).toBeInTheDocument()
expect(screen.getByTestId('past-due-update-payment')).toBeInTheDocument()
})
it('renders complimentary message and hides CTAs when complimentary', () => {
setBilling({
subscription: {
status: 'complimentary',
plan: 'pro',
current_period_start: '2026-04-01T00:00:00Z',
current_period_end: null,
cancel_at_period_end: false,
seat_limit: null,
has_pro_entitlement: true,
is_paid: true,
},
})
renderPage()
expect(screen.getByTestId('complimentary-message')).toBeInTheDocument()
expect(screen.queryByTestId('manage-billing-button')).not.toBeInTheDocument()
expect(screen.queryByTestId('select-plan-link')).not.toBeInTheDocument()
expect(screen.queryByTestId('change-plan-link')).not.toBeInTheDocument()
})
it('renders canceled message + Pick a plan CTA when canceled', () => {
setBilling({
subscription: {
status: 'canceled',
plan: 'pro',
current_period_start: '2026-03-01T00:00:00Z',
current_period_end: '2026-04-01T00:00:00Z',
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: false,
is_paid: false,
},
})
renderPage()
expect(screen.getByTestId('canceled-message')).toBeInTheDocument()
expect(screen.getByTestId('select-plan-link')).toBeInTheDocument()
})
it('shows toast when portal session fails with no_stripe_customer', async () => {
setBilling({
subscription: {
status: 'active',
plan: 'pro',
current_period_start: '2026-04-01T00:00:00Z',
current_period_end: '2026-05-01T00:00:00Z',
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: true,
is_paid: true,
},
})
getPortalSession.mockRejectedValueOnce(
new BillingPortalError('no_stripe_customer'),
)
renderPage()
fireEvent.click(screen.getByTestId('manage-billing-button'))
await waitFor(() => {
expect(toastError).toHaveBeenCalledWith(
'Complete checkout first to access billing portal.',
)
})
})
})

View File

@@ -0,0 +1,178 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { SelectPlanPage } from '../SelectPlanPage'
import { useBillingStore } from '@/store/billingStore'
vi.mock('@/api/billing', () => ({
billingApi: {
createCheckoutSession: vi.fn(),
},
}))
vi.mock('@/api/plans', () => ({
plansApi: {
getPublic: vi.fn(),
},
}))
vi.mock('@/lib/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}))
import { billingApi } from '@/api/billing'
import { plansApi } from '@/api/plans'
const createCheckoutSession = billingApi.createCheckoutSession as unknown as ReturnType<typeof vi.fn>
const getPublic = plansApi.getPublic as unknown as ReturnType<typeof vi.fn>
const PLAN_FIXTURE = [
{
plan: 'starter',
display_name: 'Starter',
description: 'For solo techs.',
monthly_price_cents: 1900,
annual_price_cents: 19000,
max_seats: 1,
sort_order: 1,
is_public: true,
},
{
plan: 'pro',
display_name: 'Pro',
description: 'For growing teams.',
monthly_price_cents: 4900,
annual_price_cents: 49000,
max_seats: 5,
sort_order: 2,
is_public: true,
},
{
plan: 'enterprise',
display_name: 'Enterprise',
description: 'Custom.',
monthly_price_cents: null,
annual_price_cents: null,
max_seats: null,
sort_order: 3,
is_public: true,
},
]
function renderPage() {
return render(
<MemoryRouter>
<SelectPlanPage />
</MemoryRouter>,
)
}
describe('SelectPlanPage', () => {
// Stub window.location.href setter so we can assert without a real navigation.
let assignedHref: string | null = null
const originalLocation = window.location
beforeEach(() => {
getPublic.mockReset()
createCheckoutSession.mockReset()
assignedHref = null
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...originalLocation,
get href() {
return assignedHref ?? originalLocation.href
},
set href(v: string) {
assignedHref = v
},
},
})
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
it('renders plan cards from plansApi', async () => {
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
renderPage()
await waitFor(() => {
expect(screen.getByTestId('plan-card-starter')).toBeInTheDocument()
})
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
expect(screen.getByTestId('plan-card-enterprise')).toBeInTheDocument()
})
it('Continue to checkout calls createCheckoutSession and redirects', async () => {
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
createCheckoutSession.mockResolvedValueOnce({ url: 'https://checkout.stripe.com/abc' })
renderPage()
await waitFor(() => {
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
})
// Bump seats and switch to annual.
fireEvent.change(screen.getByTestId('seats-input'), { target: { value: '3' } })
fireEvent.click(screen.getByTestId('interval-annual'))
fireEvent.click(screen.getByTestId('plan-cta-pro'))
await waitFor(() => {
expect(createCheckoutSession).toHaveBeenCalledWith({
plan: 'pro',
seats: 3,
billing_interval: 'annual',
})
})
await waitFor(() => {
expect(assignedHref).toBe('https://checkout.stripe.com/abc')
})
})
it('Talk to sales links to /contact-sales for enterprise', async () => {
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
renderPage()
await waitFor(() => {
expect(screen.getByTestId('plan-cta-enterprise')).toBeInTheDocument()
})
const cta = screen.getByTestId('plan-cta-enterprise') as HTMLAnchorElement
expect(cta.getAttribute('href')).toBe('/contact-sales')
})
it('marks the active current plan as Current plan and disables its CTA', async () => {
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
useBillingStore.setState({
subscription: {
status: 'active',
plan: 'pro',
current_period_start: '2026-04-01T00:00:00Z',
current_period_end: '2026-05-01T00:00:00Z',
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: true,
is_paid: true,
},
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
renderPage()
await waitFor(() => {
expect(screen.getByTestId('plan-current-pro')).toBeInTheDocument()
})
const cta = screen.getByTestId('plan-cta-pro') as HTMLButtonElement
expect(cta).toBeDisabled()
})
})

View File

@@ -88,7 +88,7 @@ export function UsersPage() {
})
const [inviteLoading, setInviteLoading] = useState(false)
const [showCreateAccountModal, setShowCreateAccountModal] = useState(false)
const [createAccountForm, setCreateAccountForm] = useState({ name: '', plan: 'free' as 'free' | 'pro' | 'team', owner_email: '' })
const [createAccountForm, setCreateAccountForm] = useState({ name: '', plan: 'free' as 'free' | 'pro' | 'starter' | 'enterprise', owner_email: '' })
const [createAccountLoading, setCreateAccountLoading] = useState(false)
const fetchAccounts = useCallback(async () => {
@@ -469,7 +469,8 @@ export function UsersPage() {
<option value="all">All plans</option>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
<option value="enterprise">Enterprise</option>
<option value="starter">Starter</option>
</select>
<select
value={statusFilter}
@@ -629,7 +630,7 @@ export function UsersPage() {
<label className="mb-1 block text-sm font-medium text-foreground">Initial Plan</label>
<select
value={createAccountForm.plan}
onChange={(e) => setCreateAccountForm((form) => ({ ...form, plan: e.target.value as 'free' | 'pro' | 'team' }))}
onChange={(e) => setCreateAccountForm((form) => ({ ...form, plan: e.target.value as 'free' | 'pro' | 'starter' | 'enterprise' }))}
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
@@ -637,7 +638,8 @@ export function UsersPage() {
>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
<option value="enterprise">Enterprise</option>
<option value="starter">Starter</option>
</select>
</div>
<div>

View File

@@ -12,8 +12,9 @@ import type { InviteCodeResponse, InviteCodeCreateRequest } from '@/types/admin'
const PLAN_OPTIONS = [
{ value: 'free', label: 'Free' },
{ value: 'starter', label: 'Starter' },
{ value: 'pro', label: 'Pro' },
{ value: 'team', label: 'Team' },
{ value: 'enterprise', label: 'Enterprise' },
] as const
const planBadgeVariant = (plan: string): 'success' | 'destructive' | 'warning' | 'default' => {
@@ -33,7 +34,7 @@ export function InviteCodesPage() {
// Form state
const [email, setEmail] = useState('')
const [expiresInDays, setExpiresInDays] = useState('')
const [assignedPlan, setAssignedPlan] = useState<'free' | 'pro' | 'team'>('free')
const [assignedPlan, setAssignedPlan] = useState<'free' | 'pro' | 'starter' | 'enterprise'>('free')
const [trialDays, setTrialDays] = useState('')
const [note, setNote] = useState('')
@@ -269,7 +270,7 @@ export function InviteCodesPage() {
aria-label="Plan"
value={assignedPlan}
onChange={(e) => {
const plan = e.target.value as 'free' | 'pro' | 'team'
const plan = e.target.value as 'free' | 'pro' | 'starter' | 'enterprise'
setAssignedPlan(plan)
if (plan === 'free') setTrialDays('')
}}

View File

@@ -22,6 +22,8 @@ const SurveyPage = lazyWithRetry(() => import('@/pages/SurveyPage'))
const SurveyThankYouPage = lazyWithRetry(() => import('@/pages/SurveyThankYouPage'))
const PrivacyPage = lazyWithRetry(() => import('@/pages/PrivacyPage'))
const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage'))
const PricingPage = lazyWithRetry(() => import('@/pages/PricingPage'))
const ContactSalesPage = lazyWithRetry(() => import('@/pages/ContactSalesPage'))
// Standalone auth pages
const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage'))
@@ -98,6 +100,8 @@ const TargetListsPage = lazyWithRetry(() => import('@/pages/account/TargetListsP
const ChatRetentionSettingsPage = lazyWithRetry(() => import('@/pages/account/ChatRetentionSettingsPage'))
const IntegrationsPage = lazyWithRetry(() => import('@/pages/account/IntegrationsPage'))
const BrandingSettingsPage = lazyWithRetry(() => import('@/pages/account/BrandingSettingsPage'))
const BillingPage = lazyWithRetry(() => import('@/pages/account/BillingPage'))
const SelectPlanPage = lazyWithRetry(() => import('@/pages/account/SelectPlanPage'))
/** Wraps a lazy-loaded page with Suspense + ErrorBoundary */
function page(Component: React.LazyExoticComponent<React.ComponentType>) {
@@ -131,6 +135,16 @@ export const router = sentryCreateBrowserRouter([
element: page(TermsPage),
errorElement: <RouteError />,
},
{
path: '/pricing',
element: page(PricingPage),
errorElement: <RouteError />,
},
{
path: '/contact-sales',
element: page(ContactSalesPage),
errorElement: <RouteError />,
},
{
path: '/login',
element: <LoginPage />,
@@ -326,6 +340,8 @@ export const router = sentryCreateBrowserRouter([
</ProtectedRoute>
),
},
{ path: 'billing', element: page(BillingPage) },
{ path: 'billing/select-plan', element: page(SelectPlanPage) },
],
},
],

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useAuthStore } from './authStore'
import type { Token } from '@/types'
// Avoid pulling in real analytics / Sentry side effects during tests.
vi.mock('@/lib/analytics', () => ({
identifyUser: vi.fn(),
resetAnalytics: vi.fn(),
analytics: {
loginSuccess: vi.fn(),
accountCreated: vi.fn(),
},
}))
vi.mock('@sentry/react', () => ({
setUser: vi.fn(),
}))
describe('authStore.setTokens', () => {
beforeEach(() => {
// Reset store to initial state between tests.
useAuthStore.setState({
user: null,
token: null,
account: null,
subscription: null,
isAuthenticated: false,
isLoading: false,
error: null,
})
})
it('marks the store as authenticated and persists the token', () => {
const fakeToken: Token = {
access_token: 'access-abc',
refresh_token: 'refresh-xyz',
token_type: 'bearer',
}
useAuthStore.getState().setTokens(fakeToken)
const state = useAuthStore.getState()
expect(state.token).toEqual(fakeToken)
expect(state.isAuthenticated).toBe(true)
})
it('keeps isAuthenticated true when called again (refresh-token path)', () => {
// Simulate an already-authenticated session (refresh interceptor case).
useAuthStore.setState({
token: {
access_token: 'old',
refresh_token: 'old-r',
token_type: 'bearer',
},
isAuthenticated: true,
})
useAuthStore.getState().setTokens({
access_token: 'new',
refresh_token: 'new-r',
token_type: 'bearer',
})
const state = useAuthStore.getState()
expect(state.token?.access_token).toBe('new')
expect(state.isAuthenticated).toBe(true)
})
})

View File

@@ -131,7 +131,13 @@ export const useAuthStore = create<AuthState>()(
}
},
setTokens: (token: Token) => set({ token }),
// Storing tokens implies an active session — mark the store as
// authenticated so <ProtectedRoute> doesn't bounce the user back to
// /landing while fetchUser() is still inflight (e.g. immediately after
// the OAuth callback exchange). The refresh interceptor in api/client.ts
// also calls this; that path is already authenticated, so flipping the
// flag has no effect there.
setTokens: (token: Token) => set({ token, isAuthenticated: true }),
clearError: () => set({ error: null }),
setLoading: (loading: boolean) => set({ isLoading: loading }),
}),

View File

@@ -10,7 +10,7 @@ export interface Account {
export interface Subscription {
id: string
account_id: string
plan: 'free' | 'pro' | 'team'
plan: 'free' | 'pro' | 'starter' | 'enterprise'
status: 'active' | 'past_due' | 'canceled' | 'trialing' | 'orphaned'
current_period_start: string | null
current_period_end: string | null

View File

@@ -113,7 +113,7 @@ export interface AdminAccountDetailResponse extends AdminAccountListItem {
export interface AdminAccountCreate {
name: string
plan: 'free' | 'pro' | 'team'
plan: 'free' | 'pro' | 'starter' | 'enterprise'
owner_email?: string
}
@@ -257,7 +257,7 @@ export interface InviteCodeCreateRequest {
expires_at?: string | null
note?: string | null
email?: string | null
assigned_plan?: 'free' | 'pro' | 'team'
assigned_plan?: 'free' | 'pro' | 'starter' | 'enterprise'
trial_duration_days?: number | null
}

View File

@@ -49,3 +49,45 @@ export interface BillingStateApiResponse {
plan_limits: Record<string, unknown>
enabled_features: Record<string, boolean>
}
/* ---------------------------------------------------------------------------
* Checkout / Customer-Portal session types
* ------------------------------------------------------------------------- */
export type CheckoutPlan = 'starter' | 'pro' | 'enterprise'
export type BillingInterval = 'monthly' | 'annual'
export interface CheckoutSessionRequest {
plan: CheckoutPlan
seats: number
billing_interval: BillingInterval
}
export interface CheckoutSessionResponse {
url: string
}
export interface BillingPortalSessionResponse {
url: string
}
/**
* Typed error codes returned by the portal-session endpoint when the call
* cannot succeed for a reason the UI should explain to the user.
*
* - `stripe_not_configured` (HTTP 503): Stripe isn't wired up server-side
* (rare — env-misconfig / dev mode).
* - `no_stripe_customer` (HTTP 400): The account has never been billed, so
* there's no Customer Portal session to open. UX: "Complete checkout
* first to access billing portal."
*/
export type BillingPortalErrorCode = 'stripe_not_configured' | 'no_stripe_customer'
export class BillingPortalError extends Error {
code: BillingPortalErrorCode
constructor(code: BillingPortalErrorCode, message?: string) {
super(message ?? code)
this.name = 'BillingPortalError'
this.code = code
}
}

View File

@@ -99,7 +99,14 @@ export type {
PlanBillingState,
BillingStatePayload,
BillingStateApiResponse,
CheckoutPlan,
BillingInterval,
CheckoutSessionRequest,
CheckoutSessionResponse,
BillingPortalSessionResponse,
BillingPortalErrorCode,
} from './billing'
export { BillingPortalError } from './billing'
export * from './scripts'
export * from './script-builder'