6 Commits

Author SHA1 Message Date
fe0e6923d5 Merge pull request 'docs(handoff): record PR #164/#165 merges; flag Stripe activation as current blocker' (#166) from docs/handoff-pr-165-merge into main
All checks were successful
CI / backend (push) Successful in 10m33s
Mirror to GitHub / mirror (push) Successful in 4s
CI / e2e (push) Successful in 9m29s
CI / frontend (push) Successful in 21m24s
2026-05-14 03:59:59 +00:00
dc22aa0ff0 docs(handoff): record PR #164/#165/#167 merges, EIN blocker, pending bug
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m42s
CI / e2e (pull_request) Successful in 10m8s
CI / backend (pull_request) Successful in 10m31s
PR #164 (taxonomy + Stripe sync + allowlist) merged as 3f04911.
PR #165 (legal/contact pages + MarketingFooter) merged as ba45cfe.
PR #167 (create_site_admin.py bootstrap script) merged as e50a215.

All code blockers for self-serve cutover are now on main. Site-admin
bootstrap script verified end-to-end against prod via railway ssh
(first prod super-admin row now exists).

Stripe live-mode activation blocked on EIN — user applying via
IRS.gov on 2026-05-13. Mailing-address decision: home address into
Stripe's private business profile temporarily; public-facing
ContactPage/PoliciesPage stays "available on request" until the
P.O. Box arrives.

Records a pending bug: user reported finding one but did not share
details — planning to send a screenshot via the VS Code extension
GUI in the next session. Next-session-first-action is updated to
capture and triage that screenshot before resuming Phase O.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:17:30 -04:00
e50a2150d5 Merge pull request 'feat(admin): add create_site_admin.py for bootstrapping a super_admin' (#167) from feat/site-admin-script into main
All checks were successful
CI / frontend (push) Successful in 6m43s
Mirror to GitHub / mirror (push) Successful in 6s
CI / e2e (push) Successful in 10m4s
CI / backend (push) Successful in 10m34s
Reviewed-on: #167

by: Michael Chihlas
2026-05-12 06:17:31 +00:00
3a3844b68e feat(admin): add create_site_admin.py for bootstrapping a super_admin
All checks were successful
CI / frontend (pull_request) Successful in 6m23s
Mirror to GitHub / mirror (push) Successful in 5s
CI / backend (pull_request) Successful in 10m10s
CI / e2e (pull_request) Successful in 9m14s
Idempotent CLI script that creates or promotes a site-wide super_admin
on any environment. Solves the prod bootstrap case where no admin
exists yet — dev's seed_test_users.py only runs in dev, self-serve
signup is still gated, and even when enabled, signup creates owner
roles, not super_admins.

The script:

- Reads --email (required), normalizes to lowercase.
- If user does not exist: creates an Account + super_admin User as
  the account owner, with email_verified_at stamped at creation and
  password_hash=NULL (forces the reset flow on first login).
- If user exists: promotes is_super_admin=true and backfills
  email_verified_at if null. Idempotent — re-running is safe.
- Mints a password-reset JWT, stores the token hash in
  password_reset_tokens, and either emails the link
  (--send-reset) or prints it to stdout (--print-reset). Email
  send is best-effort with a fallback URL on stdout so a
  misconfigured EmailService never blocks login.
- --promote-only flag: skips creation, only promotes an existing
  user. Useful for promoting an already-self-served user without
  triggering an unnecessary reset.

Uses ADMIN_DATABASE_URL when set (BYPASSRLS — required because users
is RLS-enabled and the script has no tenant context at bootstrap).

Smoke-tested in dev against all three paths: fresh create, re-run
idempotency on the same email, --promote-only on an existing user
with no password.

Intended invocation on prod, once Stripe/EIN unblocks:

  railway run python -m scripts.create_site_admin \
    --email michael@resolutionflow.com \
    --send-reset

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 01:58:53 -04:00
ba45cfeec1 feat(legal): add /policies, /contact, /promotions pages + MarketingFooter (#165)
All checks were successful
CI / frontend (push) Successful in 6m47s
Mirror to GitHub / mirror (push) Successful in 6s
CI / e2e (push) Successful in 10m16s
CI / backend (push) Successful in 11m13s
Adds the three legal/contact pages needed for Stripe live-mode site review: /policies (consolidated customer policies — refunds, cancellation, legal restrictions, promotions), /contact (phone (470) 949-4131 + support/sales/billing/security inboxes), /promotions (stub satisfying §6.2 cross-ref).

Extracts the existing landing footer into components/common/MarketingFooter.tsx and mounts it on /pricing and /contact-sales so all four legal links are reachable from every marketing surface.

Privacy and Terms closing sections updated to point at /contact + /policies; stale hello@ mailto removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-12 05:23:43 +00:00
3f04911070 feat(billing): plan taxonomy reconciliation + Stripe sync + internal-tester allowlist (#164)
All checks were successful
CI / frontend (push) Successful in 6m40s
Mirror to GitHub / mirror (push) Successful in 7s
CI / e2e (push) Successful in 10m7s
CI / backend (push) Successful in 10m34s
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-11 05:07:07 +00:00
48 changed files with 1465 additions and 134 deletions

View File

@@ -1,10 +1,13 @@
# CURRENT_TASK.md
**Active task:** Self-serve signup Phase 2 — PR #162 is open on `feat/self-serve-signup-phase-2`. Current focus is resolving its failing Gitea checks. Phase O manual ops (Stripe live setup, internal validation, flag flip) remain pending after review/merge. See `.ai/HANDOFF.md` for the resume point.
**Active task:** Phase O cutover for self-serve signup. All code blockers are now closed on `main`. Only user-side manual ops remain: apex DNS fix at Namecheap, Stripe Dashboard live-mode config (with the new `/contact` and `/policies` URLs surfaced in the business profile), Railway prod env vars, internal validation pass, public flag flip. See `.ai/HANDOFF.md` for the resume point.
## Recently shipped
- **2026-05-06`feat/self-serve-signup-phase-2`** Phase 2 frontend cutover code (Tasks 2744 of the plan, 18 commits). 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. Phase O (Stripe live setup, internal validation, flag flip) is operational and pending. Single alembic head `c6cbfc534fad` (no new migrations).
- **2026-05-12PR #165** Legal/contact pages for Stripe site review. Squash-merged into main as `ba45cfe`. Three new SPA pages: `/policies` (consolidated Customer Policies — refunds, cancellation, U.S. legal/export restrictions, promotional terms; anchor IDs per subsection), `/contact` (phone (470) 949-4131, support/sales/billing/security inboxes, response-time SLAs), `/promotions` (stub satisfying Policies §6.2). New `MarketingFooter` component (`components/common/MarketingFooter.tsx`) extracted from inline landing footer; mounted on `/landing`, `/pricing`, `/contact-sales` so all four legal links (Privacy/Terms/Policies/Contact) are reachable from every marketing surface. Component reuses existing `landing-footer*` CSS — must be inside a `.landing-page` wrapper (documented in JSX comment). Privacy and Terms closing sections updated to point at `/contact` + `/policies` with correct per-area inboxes; stale `hello@` mailto removed everywhere. Mailing address left as TODO comments in both `ContactPage.tsx` and `PoliciesPage.tsx`, rendered publicly as "available on request" until P.O. Box is purchased. tsc + eslint clean.
- **2026-05-08 — PR #164** 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,54 @@
---
## 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.

View File

@@ -2,56 +2,59 @@
# HANDOFF.md
**Last updated:** 2026-05-07 (PR #162 CI investigation/fixes)
**Last updated:** 2026-05-12
**Active task:** PR #162 (`feat/self-serve-signup-phase-2`) is open in Gitea. Current session is resolving its failing checks.
**Active task:** Phase O cutover for self-serve signup. All code blockers are closed on `main` (PR #164 `3f04911`, PR #165 `ba45cfe`, PR #167 `e50a215`). **Currently blocked on Stripe live-mode activation — root cause is EIN, not code.** User does not yet have an EIN for ResolutionFlow, LLC; Stripe requires a tax ID for live-mode activation. Applying via IRS.gov on 2026-05-13. Mailing-address decision made 2026-05-12: user will enter home address into the Stripe business profile temporarily so live-mode isn't blocked on the P.O. Box; the **public-facing** mailing-address `TODO` in `ContactPage.tsx` and `PoliciesPage.tsx` stays "available on request" until the P.O. Box is set up (do NOT put the home address on the public site). Stripe address can be updated to the P.O. Box later without re-verification. Apex DNS at Namecheap is still missing (separate user-side issue tracked below); only matters once Stripe runs its site-verification step, which happens after the business-profile fields are accepted. Nothing on the code side blocks live-mode flip.
**Bug pending capture (2026-05-12):** User reported finding a bug during the session but did not provide details — planning to send a screenshot via the VS Code extension GUI in the next session. Ask for the screenshot at session start, then triage. No further context yet.
## Where this session ended
PR #162 originally failed quickly in Gitea CI. Public Gitea status metadata was available, but job logs redirected to login and no `GITEA_TOKEN` was present. The branch was pushed over SSH.
PR #167 merged (`e50a215 Merge pull request '...create_site_admin.py...'`). Bootstrap script for the site-wide super-admin: `backend/scripts/create_site_admin.py`. Idempotent — creates or promotes a `super_admin` on any env. Reads `--email`, optionally `--send-reset` (mails the reset link) or `--print-reset` (prints to stdout) or `--promote-only`. Uses `ADMIN_DATABASE_URL` for BYPASSRLS. Verified end-to-end against the deployed Railway backend container by the user via `railway ssh` ("we're good now" confirmation, 2026-05-12 evening). Intended prod invocation when Stripe/EIN clears: `railway run python -m scripts.create_site_admin --email michael@resolutionflow.com --send-reset` from inside the backend container shell (not local Windows — local Python lacks the dep tree).
Fixed environment drift first:
Earlier in the session, PR #165 squash-merged (`ba45cfe feat(legal): add /policies, /contact, /promotions pages + MarketingFooter (#165)`):
- Standardized backend native/dev/CI Python on 3.12.13 to match Docker.
- Added `.python-version`.
- Rebuilt `backend/venv` from pyenv Python 3.12.13 and verified native `pytest --version` / `alembic --version` with explicit local env.
- Updated Gitea CI backend/e2e Python setup to 3.12.
- **New pages**, all SPA, matching existing `/privacy` and `/terms` pattern: `/policies` (consolidated Customer Policies — customer service contact, return/refund/dispute policy, cancellation, U.S. legal and export restrictions, promotional terms; anchor IDs per subsection), `/contact` (phone **(470) 949-4131**, support/sales/billing/security inboxes, response-time SLAs), `/promotions` (stub stating no promotions currently active — satisfies Policies §6.2 cross-ref).
- **`MarketingFooter`** (`frontend/src/components/common/MarketingFooter.tsx`) extracted from inline landing footer and mounted on `/landing`, `/pricing`, `/contact-sales`. Reuses existing `landing-footer*` CSS — must be rendered inside a `.landing-page` wrapper (documented in a JSX comment) because `--lp-*` vars are scoped there. All four legal links (Privacy / Terms / Policies / Contact) are now reachable from every marketing surface.
- **Privacy and Terms closing sections** updated to point at `/contact` + `/policies` and the correct inbox per area (`security@` and `support@` respectively). Stale `hello@resolutionflow.com` mailto removed everywhere.
- **Mailing address** left as TODO comments in `ContactPage.tsx` and `PoliciesPage.tsx` (one each). Rendered publicly as "available on request — email support@". Fill in when the P.O. Box is purchased.
Fixed Gitea runner assumptions next:
`tsc --project tsconfig.app.json --noEmit` and `eslint` clean. Local `vite build` and `tsc -b` are blocked by root-owned `node_modules/.tmp` and `node_modules/.vite-temp` cache directories — CI rebuilds from a clean env and was green.
- Added `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs.
- Pushed `fix(ci): set up node in gitea workflow`.
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 handoffs as "do not stage").
Local frontend validation then exposed real lint failures in Phase 2 React code under the current lint stack. The current WIP fixes:
- `react-refresh/only-export-components` for exported pure helpers used by tests/shared invite OAuth code.
- `react-hooks/set-state-in-effect` warnings where local state intentionally mirrors route/config/cache state.
- `react-hooks/purity` warnings from `Date.now()` during render.
- Redundant loading-state write in pricing page.
Validation after those frontend changes:
- `docker exec -w /app resolutionflow_frontend npm run lint` passed.
- `docker exec -w /app resolutionflow_frontend npm run test:coverage` passed (`198` tests).
- `docker exec -w /app -e NODE_OPTIONS=--max-old-space-size=4096 resolutionflow_frontend npm run build` passed.
Known local noise:
- React `act(...)` warnings appeared in existing tests during coverage but did not fail the suite.
- Vite emitted large chunk warnings during build.
- Unrelated dirty/untracked files remain and should not be staged unless explicitly requested: `docker-compose.dev.yml`, `.env.example`, `abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`, `core.*`, `docs/architecture/`, `docs/tutorials/`.
Single alembic head: `4ce3e594cb87` (no schema changes in this PR).
## Resume point
1. Commit the frontend lint fixes and `.ai/` handoff updates with the required Codex trailer.
2. Push `feat/self-serve-signup-phase-2`.
3. Poll Gitea PR #162 statuses for the new head SHA:
`curl -fsSL https://gitea.resolutionflow.com/api/v1/repos/chihlasm/resolutionflow/statuses/<sha> | python -m json.tool`
4. If statuses are still pending, report that local frontend CI is green and Gitea runner work is queued/running. If a check fails, public statuses may show only the context/description; logs require authenticated Gitea access.
**First thing next session:** ask the user for the bug screenshot (mentioned at end of 2026-05-12 session — they were planning to send it via VS Code extension GUI). Triage that before resuming Phase O work.
After that — **Phase O manual ops, all user-side, all gated on EIN landing first:**
1. **EIN application** (user, 2026-05-13 via IRS.gov). Without this, Stripe live-mode can't activate.
2. **Stripe Dashboard live-mode** (once EIN is in hand):
- 3 Products (Starter, Pro, Enterprise). 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.
- **Business profile fields**: Customer service URL `https://resolutionflow.com/contact`. Refund/cancellation policy URL `https://resolutionflow.com/policies`. Terms `https://resolutionflow.com/terms`. Privacy `https://resolutionflow.com/privacy`. Phone `(470) 949-4131`. Mailing address = user's home address temporarily (private Stripe field; will swap to P.O. Box later without re-verification). EIN = the newly-issued tax ID.
3. **Apex DNS fix at Namecheap** (re-add `@` ALIAS → `c9g7uku8.up.railway.app`, or re-add apex as a Railway custom domain). Becomes the next blocker once Stripe runs its site-verification step.
4. **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.
5. **Bootstrap prod super-admin** via the new `create_site_admin.py` script (PR #167, on main as `e50a215`). Run from inside the backend container shell (not local Windows): `railway ssh --service=<backend-service-name>` then `python -m scripts.create_site_admin --email michael@resolutionflow.com --send-reset`. Reset email arrives at `michael@resolutionflow.com`, user clicks the link, sets a password, logs in as super_admin. Idempotent — safe to re-run.
6. **Sync Stripe → DB**: `railway run python -m scripts.sync_stripe_plan_ids` (or via `railway ssh` for the same reason as #5). Verify `plan_billing` rows have `sk_live_*` price IDs.
7. **Internal validation (Phase O Task 46)**: 9 scenarios with internal testers whose emails match `INTERNAL_TESTER_EMAILS`.
8. **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.
## Open issues from prior session (non-code, user-side)
- **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.
## Carry-forward
- Phase O manual ops remain pending after PR review/merge: Stripe live setup, internal validation, feature-flag flip.
- Backend env: `SALES_LEAD_RECIPIENT_EMAIL`.
- Frontend env: `VITE_SELF_SERVE_ENABLED`, `VITE_GOOGLE_CLIENT_ID`, `VITE_MS_CLIENT_ID`, `VITE_OAUTH_REDIRECT_BASE`, `VITE_CALENDLY_URL`.
- Single alembic head remains `c6cbfc534fad`; Phase 2 added no migrations.
- Annual pricing intentionally NOT implemented — user wants exit flexibility. Schema columns preserved as nullable. `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 at `~/.gstack/projects/chihlasm-resolutionflow/abc-feat-self-serve-signup-phase-2-design-20260507-112020.md` (documentation-builder thesis). NOT yet adopted as roadmap — gated on 3 cold calls with external Directors of Onboarding.
- Mailing address fill-in: search for `TODO: replace with full mailing address` in `frontend/src/pages/ContactPage.tsx` and `frontend/src/pages/PoliciesPage.tsx` (one each) once P.O. Box is purchased.
- `backend/scripts/create_site_admin.py` is the durable site-admin bootstrap tool — use for first prod admin, recovery, or promoting future teammates. Idempotent. Three modes: `--send-reset`, `--print-reset`, `--promote-only`. Run from inside the deployed backend container via `railway ssh`, not from local Windows (local Python lacks the dep tree).
- Bot-crawlability of legal pages: still SPA-rendered. Stripe didn't enforce content scraping last time (issue turned out to be DNS). If a future vendor review flags it, pre-render with `vite-plugin-prerender-spa` (~half day).
- 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

@@ -12,6 +12,84 @@
---
## 2026-05-12 ~06:30 UTC — Claude — PR #167 (site-admin bootstrap script) merged; bug pending capture
**Accomplished:**
- User reported being unable to log into prod with `admin@resolutionflow.example.com` — that's the dev seed email (`.example.com` is a documentation TLD), only present in dev. Prod has no admin user at all because `seed_test_users.py` doesn't run in prod, self-serve is still gated, and even when it flips on signup creates `owner` roles not `super_admin`.
- Designed and built `backend/scripts/create_site_admin.py` — idempotent CLI script for creating or promoting a site-wide super-admin on any environment. Three modes: `--send-reset` (mails reset link), `--print-reset` (stdout reset link), `--promote-only` (promote existing user without creating). Creates an `Account` first, then a `User` with `is_super_admin=true`, `account_role='owner'`, `email_verified_at` stamped at creation, `password_hash=NULL` (forces the reset flow on first login). Uses `ADMIN_DATABASE_URL` (BYPASSRLS) — required because `users` is RLS-enabled and the script has no tenant context at bootstrap. Reset token mints via existing `create_password_reset_token` helper, hashes JTI into `password_reset_tokens` row matching the `/auth/password/forgot` shape.
- Smoke-tested all three paths in the dev container before pushing: fresh create on a new email (Account + User + reset URL emitted), idempotent re-run on same email (SKIP message + new reset URL), `--promote-only` on a user with `password_hash=NULL` (promotes + issues reset). Cleaned up the dev test row + account afterwards.
- Initial bug: had `used: false` in the `password_reset_tokens` INSERT — actual column is `used_at` (nullable timestamp, NULL means "not used"). Fixed before pushing.
- PR #167 opened, CI green, squash-merged into main as `e50a215`. Remote branch `feat/site-admin-script` auto-deleted.
- User confirmed end-to-end success on prod via `railway ssh --service=<backend>` then `python -m scripts.create_site_admin ...` ("we're good now"). Specific service name not captured. First prod super-admin row now exists in the prod DB.
- Stripe live-mode activation block traced to EIN, not code (user does not yet have an EIN for ResolutionFlow, LLC). Applying via IRS.gov 2026-05-13. Mailing-address decision: home address into Stripe's **private** business profile temporarily so live-mode isn't blocked on the P.O. Box; public `ContactPage`/`PoliciesPage` stays "available on request". Stripe accepts address update later without re-verification.
- PR #166 (docs handoff for PR #164/#165 merges + EIN decision) still open from earlier in this same session — was never merged. This entry rebases the docs branch onto current main (which now includes PR #167) and adds the PR #167 narrative + bug-pending state so a fresh session has the full picture in one merge.
- User reported finding a bug in a UI surface but did not provide details — planning to send a screenshot via the VS Code extension GUI in the next session (CLI is unreliable for them). Next session: ask for the screenshot at session start, then triage.
**Left for next session:**
- Get the bug screenshot from the user, triage, fix or scope.
- Otherwise everything that was on the prior entry's left-for-next-session still stands: EIN application Tuesday 2026-05-13, then Stripe live-mode setup, apex DNS at Namecheap, Railway prod env vars, internal validation, flag flip.
**Files touched (all merged to main via PR #167 squash `e50a215`):** `backend/scripts/create_site_admin.py` (new, ~270 lines including docstring). Plus `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md` on `docs/handoff-pr-165-merge` (PR #166, awaiting merge).
---
## 2026-05-12 05:30 UTC — Claude — PR #164 + #165 merged; Stripe activation reported blocked
**Accomplished:**
- Resumed from compacted context. Confirmed PR #164 (`feat/billing-plan-taxonomy`, head `2c9f5e9`) was already CI-green at session start and squash-merged into main as `3f04911` earlier in the session (occurred pre-compaction; reflected in the prior HANDOFF revision). Branch auto-deleted on remote.
- User raised the legal/contact pages question in conversation. Verified existing state of `frontend/src/pages/{PrivacyPage,TermsPage}.tsx` — both already contain real, dated content (last updated 2026-03-21) but are SPA-rendered. Discussed Stripe's site-review needs with the user and agreed to build a consolidated Customer Policies page plus a Contact page (now that the user has a business phone number) plus a Promotions stub to satisfy Policies §6.2 cross-reference. User authorized the work.
- Built PR #165 (`feat/stripe-legal-pages`, head `545b2ad`):
- **`/policies``frontend/src/pages/PoliciesPage.tsx`** (new). Consolidated Customer Policies doc, 8 sections with anchor IDs per subsection so Stripe (or a support email) can deep-link: customer service contact (with phone (470) 949-4131), return policy (n/a — SaaS), refund / dispute policy, cancellation policy, U.S. legal and export restrictions (Georgia governing law, OFAC / BIS compliance, sanctioned-jurisdiction exclusion), promotional terms (general + cross-ref to `/promotions`), changes-to-policies, relationship-to-other-agreements. Mailing address left as in-source `TODO` comment, rendered publicly as "available on request — email support@" until P.O. Box is purchased.
- **`/contact``frontend/src/pages/ContactPage.tsx`** (new). Phone **(470) 949-4131**, all four inboxes (`support@`, `sales@`, `billing@`, `security@`), response-time SLAs, mailing-address placeholder, link to `/contact-sales` for the lead-gen Calendly flow (distinct surface — kept both routes intentionally).
- **`/promotions``frontend/src/pages/PromotionsPage.tsx`** (new). One-paragraph stub stating no promotions currently active. Will be appended to when offers run; satisfies Policies §6.2's cross-reference.
- Routes wired in `frontend/src/router.tsx` as 3 new public lazy-loaded routes alongside existing `/privacy`, `/terms`, `/pricing`, `/contact-sales`.
- **`MarketingFooter``frontend/src/components/common/MarketingFooter.tsx`** (new, second commit). Extracted from the inline landing footer (26 lines → 1 line at the call site). Mounted on `/landing`, `/pricing`, `/contact-sales` so all four legal links (Privacy / Terms / Policies / Contact) are reachable from every marketing surface — including the page Stripe's reviewer spends the most time on (`/pricing`). Reuses existing `landing-footer*` CSS in `frontend/src/styles/landing.css` — must be rendered inside a `.landing-page` wrapper because `--lp-*` vars are scoped there (documented in a JSX comment). All three current call sites already wrap in `.landing-page`, so landing renders pixel-identically and the two new mount sites match.
- **Privacy and Terms closing sections** updated to point at `/contact` + `/policies` with correct per-area inboxes (`security@` for Privacy, `support@` for Terms). Stale `hello@resolutionflow.com` mailto removed everywhere.
- `tsc --project tsconfig.app.json --noEmit` clean, `eslint` clean. Local `vite build` and `tsc -b` blocked by root-owned `node_modules/.tmp` and `node_modules/.vite-temp` cache directories — CI rebuilds from a clean env and was green.
- PR #165 opened at `gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/165`, CI passed, squash-merged into main as `ba45cfe`. Remote branch `feat/stripe-legal-pages` auto-deleted.
- User reports continued trouble activating Stripe live mode. After follow-up: the real blocker is the EIN — ResolutionFlow, LLC does not have one yet, and Stripe requires a tax ID before it will activate live mode. User is applying via IRS.gov on 2026-05-13. Updated HANDOFF.md to remove the earlier speculation list and record EIN as the named blocker, with the P.O. Box / mailing address called out as the likely-next blocker (Stripe live-mode also requires a business mailing address). Apex DNS at Namecheap is still pending but only matters after the business profile is accepted (site verification is a downstream step).
- Mailing-address decision: user is going with the home-address-temporarily approach for Stripe so live-mode isn't blocked on the P.O. Box. Home address goes into Stripe's **private** business profile only — the **public** `TODO: replace with full mailing address` in `ContactPage.tsx` and `PoliciesPage.tsx` stays as "available on request" until the P.O. Box is purchased. Stripe accepts updating the address later without re-verification, so swapping in the P.O. Box when it arrives is non-disruptive.
**Left for next session:**
- Check in on whether the EIN application went through and whether the P.O. Box / mailing address is sorted. Both are pure user-side ops; no code work to do until Stripe accepts the business profile.
- Once Stripe is activated: Stripe Dashboard live-mode product/price/webhook setup, Railway prod env vars, `railway run python -m scripts.sync_stripe_plan_ids` against prod, 9-scenario internal validation, flag flip.
- Apex DNS at Namecheap (still missing; only matters once Stripe runs its site-verification step).
- Mailing address TODO in `ContactPage.tsx` and `PoliciesPage.tsx` (one each) — fill in when P.O. Box is purchased.
**Files touched (all merged to main via PR #165 squash `ba45cfe`):** `frontend/src/pages/ContactPage.tsx` (new), `frontend/src/pages/PoliciesPage.tsx` (new), `frontend/src/pages/PromotionsPage.tsx` (new), `frontend/src/components/common/MarketingFooter.tsx` (new), `frontend/src/router.tsx`, `frontend/src/pages/LandingPage.tsx`, `frontend/src/pages/PricingPage.tsx`, `frontend/src/pages/ContactSalesPage.tsx`, `frontend/src/pages/PrivacyPage.tsx`, `frontend/src/pages/TermsPage.tsx`. Plus `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md` on the `docs/handoff-pr-165-merge` branch (this entry).
---
## 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.

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

@@ -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

@@ -13,8 +13,8 @@
```bash
# 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

@@ -29,4 +29,14 @@ CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
# 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_
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

@@ -150,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(

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

@@ -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"

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

@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""
Create or promote a site-wide super-admin user on any environment.
Designed for the prod bootstrap case where no admin exists yet and self-serve
signup is gated, so there is no way to obtain admin access through the UI.
Also safe to use as a recovery tool: if an admin email exists already, the
script just promotes them to `is_super_admin=True` instead of duplicating.
Usage:
# Bootstrap a fresh super-admin and email a password-reset link:
python -m scripts.create_site_admin --email michael@resolutionflow.com --send-reset
# Same but emit the reset URL on stdout instead of sending email (useful
# if email infra is not configured yet or if you want to bypass the inbox):
python -m scripts.create_site_admin --email michael@resolutionflow.com --print-reset
# Promote an existing user (no reset needed if they already have a password):
python -m scripts.create_site_admin --email michael@resolutionflow.com --promote-only
The script is idempotent. Running it twice on the same email is safe.
"""
from __future__ import annotations
import argparse
import asyncio
import random
import string
import sys
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from app.core.config import settings
from app.core.email import EmailService
from app.core.security import (
create_password_reset_token,
decode_token,
hash_token,
)
def _display_code() -> str:
return "".join(random.choices(string.ascii_uppercase + string.digits, k=8))
async def _find_user(conn: AsyncConnection, email: str):
result = await conn.execute(
text(
"SELECT id, account_id, is_super_admin, password_hash "
"FROM users WHERE email = :email"
),
{"email": email},
)
return result.first()
async def _create_user_and_account(
conn: AsyncConnection,
email: str,
name: str,
account_name: str,
now: datetime,
) -> uuid.UUID:
"""Create a new Account and a new super-admin User as its owner.
Mirrors the shape used by seed_test_users.py for the super-admin row,
minus the shared dev password — this bootstrap user gets no password
until the reset flow runs.
"""
account_id = uuid.uuid4()
user_id = uuid.uuid4()
await conn.execute(
text(
"""
INSERT INTO accounts (id, name, display_code, created_at, updated_at)
VALUES (:id, :name, :code, :now, :now)
"""
),
{"id": account_id, "name": account_name, "code": _display_code(), "now": now},
)
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, email_verified_at
)
VALUES (
:id, :email, NULL, :name, 'engineer', true,
false, true, :account_id, 'owner',
:now, :now
)
"""
),
{
"id": user_id,
"email": email,
"name": name,
"account_id": account_id,
"now": now,
},
)
await conn.execute(
text("UPDATE accounts SET owner_id = :uid WHERE id = :aid"),
{"uid": user_id, "aid": account_id},
)
return user_id
async def _promote_existing(conn: AsyncConnection, user_id: uuid.UUID, now: datetime) -> None:
"""Promote an existing user to super-admin and backfill verification."""
await conn.execute(
text(
"""
UPDATE users
SET is_super_admin = true,
email_verified_at = COALESCE(email_verified_at, :now),
is_active = true
WHERE id = :uid
"""
),
{"uid": user_id, "now": now},
)
async def _issue_reset_link(
conn: AsyncConnection, user_id: uuid.UUID, send_email: bool, email: str
) -> Optional[str]:
"""Generate a password-reset token, persist its hash, and return the URL.
Mirrors /auth/password/forgot. We commit the token row directly because
the script owns its own transaction (not the API request lifecycle).
"""
raw_token = create_password_reset_token(str(user_id))
payload = decode_token(raw_token)
if not payload or not payload.get("jti"):
return None
await conn.execute(
text(
"""
INSERT INTO password_reset_tokens
(id, token_hash, user_id, expires_at, created_at)
VALUES (:id, :token_hash, :user_id, :expires_at, :created_at)
"""
),
{
"id": uuid.uuid4(),
"token_hash": hash_token(payload["jti"]),
"user_id": user_id,
"expires_at": datetime.fromtimestamp(payload["exp"], tz=timezone.utc),
"created_at": datetime.now(timezone.utc),
},
)
reset_url = f"{settings.FRONTEND_URL}/reset-password?token={raw_token}"
if send_email:
# Best-effort. If email infra is misconfigured, the caller still
# has --print-reset as a fallback.
try:
await EmailService.send_password_reset_email(
to_email=email, reset_url=reset_url
)
except Exception as exc: # noqa: BLE001
print(f" [WARN] Email send failed: {exc}")
print(f" [WARN] Use the printed URL below as a fallback.")
return reset_url
async def main(args: argparse.Namespace) -> int:
email = args.email.strip().lower()
name = args.name or email.split("@", 1)[0].title()
account_name = args.account_name or "ResolutionFlow Admin"
admin_url = getattr(settings, "ADMIN_DATABASE_URL", None) or settings.DATABASE_URL
engine = create_async_engine(admin_url, echo=False)
now = datetime.now(timezone.utc)
try:
async with engine.begin() as conn:
existing = await _find_user(conn, email)
if existing is None:
if args.promote_only:
print(f"[ERROR] --promote-only set but no user with email {email!r} exists.")
return 1
user_id = await _create_user_and_account(
conn, email, name, account_name, now
)
print(f" [OK] Created super-admin user {email} (id={user_id})")
else:
user_id = existing.id
if existing.is_super_admin:
print(f" [SKIP] {email} already exists and is super-admin (id={user_id})")
else:
await _promote_existing(conn, user_id, now)
print(f" [OK] Promoted {email} to super-admin (id={user_id})")
# Skip reset issuance for --promote-only when the user already
# has a password (they can just log in with their existing creds).
should_issue_reset = not args.promote_only or (
existing is not None and existing.password_hash is None
)
if should_issue_reset:
reset_url = await _issue_reset_link(
conn, user_id, send_email=args.send_reset, email=email
)
if reset_url is None:
print(" [ERROR] Failed to mint password-reset token.")
return 2
print()
if args.send_reset:
print(f" [OK] Password-reset email sent to {email}")
print(f" [INFO] Reset link (also emailed) — copy if email is delayed:")
if args.print_reset or not args.send_reset:
print(f" {reset_url}")
else:
print(f" {reset_url}")
print()
print(" This link expires per PASSWORD_RESET_TOKEN_EXPIRE in settings.")
finally:
await engine.dispose()
return 0
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="create_site_admin",
description="Create or promote a site-wide super-admin user.",
)
p.add_argument("--email", required=True, help="Email of the admin (will be normalized to lowercase).")
p.add_argument("--name", help="Display name. Defaults to the local part of the email, title-cased.")
p.add_argument(
"--account-name",
help="Account name to create alongside a brand-new user. Ignored if the user already exists. Defaults to 'ResolutionFlow Admin'.",
)
mode = p.add_mutually_exclusive_group()
mode.add_argument(
"--send-reset",
action="store_true",
help="Send a password-reset email to the admin. The reset URL is also printed to stdout as a fallback.",
)
mode.add_argument(
"--print-reset",
action="store_true",
help="Mint a reset token and print the URL to stdout WITHOUT sending email. Use when email infra is not configured.",
)
mode.add_argument(
"--promote-only",
action="store_true",
help="Only promote an existing user to super-admin. Will NOT create a new user, and will NOT issue a reset link unless the existing user has no password.",
)
return p
if __name__ == "__main__":
print("\n[*] ResolutionFlow — Site Admin Bootstrap")
print("=" * 60)
args = _build_parser().parse_args()
sys.exit(asyncio.run(main(args)))

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

@@ -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

@@ -14,7 +14,12 @@ 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 for the given plan name."""
"""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(
@@ -28,7 +33,9 @@ async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None:
export_formats=["markdown", "text"],
)
)
await test_db.commit()
else:
existing.max_users = max_users
await test_db.commit()
class TestGetPlansPublic:

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

@@ -0,0 +1,35 @@
import { Link } from 'react-router-dom'
// Styles live in src/styles/landing.css under `.landing-footer*`. The component
// must be rendered inside a `.landing-page` wrapper so the `--lp-*` CSS
// variables resolve. All current marketing surfaces (LandingPage,
// PricingPage, ContactSalesPage) already provide that wrapper.
export function MarketingFooter() {
return (
<footer className="landing-footer">
<div className="landing-footer-inner">
<div className="landing-footer-left">
<div className="landing-nav-logo-icon" style={{ width: 24, height: 24, borderRadius: 6 }}>
<svg viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ width: 14, height: 14 }}>
<circle cx="12" cy="5" r="2" />
<line x1="12" y1="7" x2="12" y2="11" />
<circle cx="6" cy="15" r="2" />
<circle cx="18" cy="15" r="2" />
<line x1="12" y1="11" x2="6" y2="13" />
<line x1="12" y1="11" x2="18" y2="13" />
</svg>
</div>
<span className="landing-footer-copy">&copy; 2026 ResolutionFlow</span>
</div>
<ul className="landing-footer-links">
<li><Link to="/privacy">Privacy</Link></li>
<li><Link to="/terms">Terms</Link></li>
<li><Link to="/policies">Policies</Link></li>
<li><Link to="/contact">Contact</Link></li>
</ul>
</div>
</footer>
)
}
export default MarketingFooter

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

@@ -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

@@ -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

@@ -418,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>

View File

@@ -0,0 +1,88 @@
import { Link } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
export default function ContactPage() {
return (
<>
<PageMeta title="Contact" description="Contact ResolutionFlow customer service, sales, billing, or security." />
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto max-w-3xl px-6 py-16">
<Link to="/landing" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">&larr; Back to home</Link>
<h1 className="text-3xl font-bold font-heading mb-4">Contact ResolutionFlow</h1>
<p className="text-muted-foreground mb-10">
We respond to customer inquiries Monday through Friday during U.S. business hours, excluding federal holidays. Email is the fastest path to a response.
</p>
<div className="space-y-8 text-muted-foreground leading-relaxed">
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">Phone</h2>
<p>
<a href="tel:+14709494131" className="text-primary hover:underline">(470) 949-4131</a>
</p>
<p className="text-sm mt-1">Monday&ndash;Friday, 9:00 AM&ndash;5:00 PM ET, excluding U.S. federal holidays.</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">Email</h2>
<ul className="space-y-2">
<li>
<strong className="text-foreground">General support:</strong>{' '}
<a href="mailto:support@resolutionflow.com" className="text-primary hover:underline">support@resolutionflow.com</a>
</li>
<li>
<strong className="text-foreground">Sales and Enterprise:</strong>{' '}
<a href="mailto:sales@resolutionflow.com" className="text-primary hover:underline">sales@resolutionflow.com</a>
</li>
<li>
<strong className="text-foreground">Billing and account:</strong>{' '}
<a href="mailto:billing@resolutionflow.com" className="text-primary hover:underline">billing@resolutionflow.com</a>
</li>
<li>
<strong className="text-foreground">Security and privacy:</strong>{' '}
<a href="mailto:security@resolutionflow.com" className="text-primary hover:underline">security@resolutionflow.com</a>
</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">Response times</h2>
<ul className="list-disc list-inside space-y-1">
<li>General support: within one (1) business day</li>
<li>Billing or account access: within one (1) business day</li>
<li>Security disclosures: within twenty-four (24) hours, including weekends</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">Mailing address</h2>
{/* TODO: replace with full mailing address once P.O. Box is set up. */}
<p>
Available on request. Email{' '}
<a href="mailto:support@resolutionflow.com" className="text-primary hover:underline">support@resolutionflow.com</a>{' '}
and we will provide our current mailing address.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">Sales and demos</h2>
<p>
Interested in a guided demo or Enterprise pricing? Use our{' '}
<Link to="/contact-sales" className="text-primary hover:underline">sales contact form</Link>{' '}
to book a time directly.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">Related</h2>
<ul className="list-disc list-inside space-y-1">
<li><Link to="/policies" className="text-primary hover:underline">Customer Policies</Link> &mdash; billing, refunds, cancellation, and promotions</li>
<li><Link to="/terms" className="text-primary hover:underline">Terms of Service</Link></li>
<li><Link to="/privacy" className="text-primary hover:underline">Privacy Policy</Link></li>
</ul>
</section>
</div>
</div>
</div>
</>
)
}

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import { salesApi, type SalesLeadSource } from '@/api/sales'
import { PageMeta } from '@/components/common/PageMeta'
import { MarketingFooter } from '@/components/common/MarketingFooter'
import { useAppConfig } from '@/hooks/useAppConfig'
import '@/styles/landing.css'
@@ -342,6 +343,8 @@ export function ContactSalesPage() {
</form>
)}
</section>
<MarketingFooter />
</main>
</div>
)

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
import { useAppConfig } from '@/hooks/useAppConfig'
import { MarketingFooter } from '@/components/common/MarketingFooter'
import '@/styles/landing.css'
const FAQ_ITEMS = [
@@ -15,7 +16,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?',
@@ -23,7 +24,7 @@ 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.',
},
]
@@ -75,8 +76,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">
@@ -410,29 +411,7 @@ export default function LandingPage() {
</div>
</section>
{/* Footer */}
<footer className="landing-footer">
<div className="landing-footer-inner">
<div className="landing-footer-left">
<div className="landing-nav-logo-icon" style={{ width: 24, height: 24, borderRadius: 6 }}>
<svg viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ width: 14, height: 14 }}>
<circle cx="12" cy="5" r="2" />
<line x1="12" y1="7" x2="12" y2="11" />
<circle cx="6" cy="15" r="2" />
<circle cx="18" cy="15" r="2" />
<line x1="12" y1="11" x2="6" y2="13" />
<line x1="12" y1="11" x2="18" y2="13" />
</svg>
</div>
<span className="landing-footer-copy">&copy; 2026 ResolutionFlow</span>
</div>
<ul className="landing-footer-links">
<li><Link to="/privacy">Privacy</Link></li>
<li><Link to="/terms">Terms</Link></li>
<li><a href="mailto:hello@resolutionflow.com">Contact</a></li>
</ul>
</div>
</footer>
<MarketingFooter />
</main>
</div>
</>

View File

@@ -0,0 +1,194 @@
import { Link } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
export default function PoliciesPage() {
return (
<>
<PageMeta title="Customer Policies" description="ResolutionFlow customer service, billing, refunds, cancellation, legal restrictions, and promotional terms." />
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto max-w-3xl px-6 py-16">
<Link to="/landing" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">&larr; Back to home</Link>
<h1 className="text-3xl font-bold font-heading mb-4">Customer Policies</h1>
<p className="text-muted-foreground mb-2">Last updated: May 7, 2026</p>
<p className="text-muted-foreground mb-2"><strong className="text-foreground">Operator:</strong> ResolutionFlow, LLC (the &ldquo;Company&rdquo;), operator of ResolutionFlow (&ldquo;Service&rdquo;).</p>
<p className="text-muted-foreground mb-8"><strong className="text-foreground">Product:</strong> ResolutionFlow &mdash; a software-as-a-service troubleshooting platform for Managed Service Providers (MSPs).</p>
<p className="text-muted-foreground mb-6 leading-relaxed">
This document consolidates the policies that govern your use of ResolutionFlow, including how to contact us, how billing works, how to cancel, how refunds and disputes are handled, the legal restrictions that apply, and the terms of any promotional offers. It is intended to satisfy the disclosure requirements of our payment processors (including Stripe) and to give customers clear, accessible answers to common billing and account questions.
</p>
<p className="text-muted-foreground mb-10 leading-relaxed">
ResolutionFlow is a digital subscription service. We do not sell or ship physical goods, so a return policy does not apply; the section on refunds below covers all circumstances in which money may be returned.
</p>
<hr className="border-border mb-10" />
<div className="space-y-10 text-muted-foreground leading-relaxed">
{/* Section 1 */}
<section id="contact">
<h2 className="text-xl font-semibold text-foreground mb-3">1. Customer Service Contact</h2>
<p>We respond to customer inquiries during standard business hours, Monday through Friday, excluding U.S. federal holidays. The fastest path to a response is email.</p>
<ul className="mt-3 space-y-1">
<li><strong className="text-foreground">Phone:</strong> <a href="tel:+14709494131" className="text-primary hover:underline">(470) 949-4131</a></li>
<li><strong className="text-foreground">Email (primary channel):</strong> <a href="mailto:support@resolutionflow.com" className="text-primary hover:underline">support@resolutionflow.com</a></li>
<li><strong className="text-foreground">Sales and Enterprise inquiries:</strong> <a href="mailto:sales@resolutionflow.com" className="text-primary hover:underline">sales@resolutionflow.com</a></li>
<li><strong className="text-foreground">Billing and account inquiries:</strong> <a href="mailto:billing@resolutionflow.com" className="text-primary hover:underline">billing@resolutionflow.com</a></li>
<li><strong className="text-foreground">Security and privacy inquiries:</strong> <a href="mailto:security@resolutionflow.com" className="text-primary hover:underline">security@resolutionflow.com</a></li>
</ul>
{/* TODO: replace with full mailing address once P.O. Box is set up. */}
<p className="mt-4"><strong className="text-foreground">Mailing address:</strong> available on request &mdash; email <a href="mailto:support@resolutionflow.com" className="text-primary hover:underline">support@resolutionflow.com</a>.</p>
<p className="mt-2"><strong className="text-foreground">Web contact form:</strong> <Link to="/contact" className="text-primary hover:underline">resolutionflow.com/contact</Link></p>
<p className="mt-4"><strong className="text-foreground">Target response times:</strong></p>
<ul className="list-disc list-inside space-y-1 mt-1">
<li>General support: within one (1) business day</li>
<li>Billing or account access issues: within one (1) business day</li>
<li>Security disclosures: within twenty-four (24) hours, including weekends</li>
</ul>
<p className="mt-3">Customers on the Enterprise plan have additional contact channels and service levels defined in their order form.</p>
</section>
{/* Section 2 */}
<section id="returns">
<h2 className="text-xl font-semibold text-foreground mb-3">2. Return Policy</h2>
<p>ResolutionFlow is a software-as-a-service product delivered electronically. Because no physical goods are sold or shipped, no return policy applies. All refund-related questions are governed by Section 3 (Refund and Dispute Policy) below.</p>
</section>
{/* Section 3 */}
<section id="refunds">
<h2 className="text-xl font-semibold text-foreground mb-3">3. Refund and Dispute Policy</h2>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">3.1 Subscription model</h3>
<p>ResolutionFlow is sold as a recurring monthly subscription. The Service is billed in advance: the first charge occurs at the end of any free trial period (or immediately if no trial applies), and subsequent charges occur on the same day each month until the subscription is cancelled. There are currently no annual subscription terms; if and when annual terms become available, refund handling for those terms will be specified at the point of sale.</p>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">3.2 Free trial</h3>
<p>New customers receive a 14-day free trial of the Pro plan. No charge is made during the trial. If the customer does not cancel before the trial ends, the subscription converts automatically to a paid plan at the price disclosed at signup. Cancelling before the trial ends prevents any charge. The trial is intended to be the customer&rsquo;s primary opportunity to evaluate the Service before paying.</p>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">3.3 Refund policy</h3>
<p><strong className="text-foreground">Monthly subscriptions are non-refundable.</strong> Because the customer can cancel at any time and is never billed for a future period after cancellation, we do not issue refunds for partial months, unused features, or change-of-mind cancellations made after the billing date. This is the standard model for B2B SaaS and is consistent with how the Service is priced and delivered.</p>
<p className="mt-3">We will issue a refund or credit at our discretion in the following circumstances:</p>
<ul className="list-disc list-inside space-y-2 mt-2">
<li><strong className="text-foreground">Duplicate or accidental charge.</strong> If a customer is charged twice for the same billing period, or charged after a verifiable cancellation that we failed to process, we refund the erroneous charge in full.</li>
<li><strong className="text-foreground">Fraudulent charge.</strong> If a customer demonstrates that their payment method was used without authorization, we cooperate with the cardholder and the issuing bank to resolve the dispute and refund or reverse the charge as appropriate.</li>
<li><strong className="text-foreground">Material Service failure.</strong> If the Service is materially unavailable for an extended period due to our fault, we may issue a service credit applied to the next billing cycle. Credits are not paid as cash refunds.</li>
<li><strong className="text-foreground">Annual prepayments (when offered).</strong> If annual prepayment becomes available in the future, the refund terms for those subscriptions will be disclosed at the point of sale.</li>
</ul>
<p className="mt-3">Refunds, where issued, are returned to the original payment method used for the charge. Refund processing typically completes within five (5) to ten (10) business days, depending on the customer&rsquo;s bank or card issuer.</p>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">3.4 Disputes and chargebacks</h3>
<p>If you believe a charge is incorrect, please contact <a href="mailto:billing@resolutionflow.com" className="text-primary hover:underline">billing@resolutionflow.com</a> <strong className="text-foreground">before</strong> initiating a chargeback with your bank or card issuer. We can almost always resolve billing questions faster than the dispute process and without affecting your account standing.</p>
<p className="mt-3">If a chargeback is filed against an active subscription, the associated account may be suspended pending resolution to prevent further charges. We respond to chargeback inquiries from card networks with the records we maintain, including signup records, billing history, login activity, and product usage. We do not retaliate against customers for filing legitimate disputes; suspensions during a dispute are operational, not punitive.</p>
<p className="mt-3">Customers who repeatedly file chargebacks for charges that they previously authorized and used may be permanently banned from the Service.</p>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">3.5 Enterprise refunds</h3>
<p>Customers on the Enterprise plan are governed by the refund and dispute terms in their executed order form or master services agreement. Where those terms conflict with this section, the order form or MSA controls.</p>
</section>
{/* Section 4 */}
<section id="cancellation">
<h2 className="text-xl font-semibold text-foreground mb-3">4. Cancellation Policy</h2>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">4.1 How to cancel</h3>
<p>Customers can cancel their subscription at any time, without contacting support, through one of the following routes:</p>
<ol className="list-decimal list-inside space-y-2 mt-2">
<li><strong className="text-foreground">Account settings &rarr; Billing &rarr; Manage subscription</strong>, which opens the Stripe Customer Portal and allows the customer to cancel directly.</li>
<li><strong className="text-foreground">Email <a href="mailto:billing@resolutionflow.com" className="text-primary hover:underline">billing@resolutionflow.com</a></strong> from the email address on file. We will process the cancellation within one (1) business day and confirm by reply.</li>
</ol>
<p className="mt-3">Customers on the Enterprise plan should follow the cancellation procedure specified in their order form or MSA.</p>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">4.2 What happens when you cancel</h3>
<ul className="list-disc list-inside space-y-2">
<li><strong className="text-foreground">No future charges.</strong> Your subscription will not renew. The card on file will not be charged again.</li>
<li><strong className="text-foreground">Access continues until the end of the current billing period.</strong> If you cancel mid-month, you retain full access to the Service until the end of the period you have already paid for. We do not lock you out early.</li>
<li><strong className="text-foreground">Trial cancellations.</strong> Cancelling during a free trial ends the trial immediately. No charge is made.</li>
<li><strong className="text-foreground">No partial refunds.</strong> Per Section 3.3, the unused portion of the current billing period is not refunded.</li>
</ul>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">4.3 What happens to your data</h3>
<p>For a period of thirty (30) days after the end of the current billing period, your account is retained in a read-only state. During this window you can:</p>
<ul className="list-disc list-inside space-y-2 mt-2">
<li>Reactivate the subscription and resume work without any data loss.</li>
<li>Export your sessions, flows, and documentation in any of the supported export formats (Markdown, plain text, HTML, PDF, or PSA-formatted notes).</li>
<li>Request a copy of your data by emailing <a href="mailto:security@resolutionflow.com" className="text-primary hover:underline">security@resolutionflow.com</a>.</li>
</ul>
<p className="mt-3">After thirty (30) days, all customer-generated content is permanently deleted from production systems. Backups are purged within ninety (90) days. Some metadata may be retained as required for tax, legal, or fraud-prevention obligations.</p>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">4.4 Reactivation</h3>
<p>A cancelled account can be reactivated within the thirty (30) day retention window by signing in and re-subscribing. Beyond that window, the customer can sign up again, but prior data will not be available.</p>
</section>
{/* Section 5 */}
<section id="legal">
<h2 className="text-xl font-semibold text-foreground mb-3">5. Legal and Export Restrictions</h2>
<p>ResolutionFlow is operated from the United States and is subject to U.S. law, including export control and economic sanctions regulations administered by the U.S. Department of the Treasury Office of Foreign Assets Control (&ldquo;OFAC&rdquo;) and the U.S. Department of Commerce Bureau of Industry and Security (&ldquo;BIS&rdquo;).</p>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">5.1 Eligibility</h3>
<p>By subscribing to or using the Service, you represent and warrant that:</p>
<ul className="list-disc list-inside space-y-2 mt-2">
<li>You are not located in, ordinarily resident in, or organized under the laws of a country or region subject to comprehensive U.S. sanctions (including, as of this date, Cuba, Iran, North Korea, Syria, and the so-called Crimea, Donetsk, and Luhansk regions of Ukraine).</li>
<li>You are not identified on the U.S. Treasury Department&rsquo;s List of Specially Designated Nationals and Blocked Persons (SDN List), the U.S. Commerce Department&rsquo;s Denied Persons List or Entity List, or any other restricted-party list maintained by the U.S. government, the United Nations Security Council, the European Union, or the United Kingdom.</li>
<li>You will not use the Service in violation of any applicable U.S. or foreign export control or sanctions law.</li>
</ul>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">5.2 Use restrictions</h3>
<p>The Service is intended for general business use by Managed Service Providers and similar IT services organizations. The Service may not be used in connection with:</p>
<ul className="list-disc list-inside space-y-2 mt-2">
<li>The design, development, production, or use of nuclear, chemical, or biological weapons, or missile systems capable of delivering such weapons.</li>
<li>Any application where failure of the Service could reasonably be expected to cause death, personal injury, or severe physical or environmental damage (including life-support systems, primary medical diagnostic systems, nuclear facilities, or air traffic control).</li>
</ul>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">5.3 Right to refuse service</h3>
<p>We reserve the right to refuse, suspend, or terminate service to any customer where we have a reasonable belief, based on the information available to us, that providing the Service would violate applicable law, sanctions, or our policies. Where service is terminated for compliance reasons, any prepaid amounts are refunded to the extent permitted by law.</p>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">5.4 Governing law and venue</h3>
<p>These policies are governed by the laws of the State of Georgia, without regard to its conflict-of-laws principles. Any dispute arising out of these policies or your use of the Service will be brought in the state or federal courts located in Cherokee County, Georgia, and you consent to the personal jurisdiction of those courts. This venue clause does not apply where prohibited by mandatory consumer protection law in your jurisdiction.</p>
</section>
{/* Section 6 */}
<section id="promotions">
<h2 className="text-xl font-semibold text-foreground mb-3">6. Promotions: Terms and Conditions</h2>
<p>From time to time we may offer promotional pricing, extended trials, referral credits, free upgrades, or other promotional benefits (&ldquo;Promotions&rdquo;). The following general terms apply to all Promotions, in addition to any specific terms disclosed at the time the Promotion is offered:</p>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">6.1 General terms</h3>
<ul className="list-disc list-inside space-y-2">
<li>Promotions apply only to the specific accounts, plans, and billing periods identified at the time of the offer.</li>
<li>Promotions cannot be combined with other Promotions unless we expressly state otherwise.</li>
<li>Promotional pricing applies for the period stated in the offer. After that period, the subscription renews at the then-current standard price for the applicable plan unless cancelled.</li>
<li>Promotional credits and discounts have no cash value and are not transferable, refundable, or redeemable for currency.</li>
<li>Customers must be in good standing (no overdue balances, no active chargebacks, no policy violations) to receive or continue receiving a Promotion.</li>
<li>We may modify or discontinue a Promotion at any time, except where doing so would be inconsistent with the terms disclosed at the time the customer accepted the Promotion.</li>
<li>We reserve the right to revoke a Promotion and recover its value if we determine, in good faith, that the customer obtained the Promotion through fraud, duplicate accounts, abuse, or violation of these terms.</li>
</ul>
<h3 className="text-base font-semibold text-foreground mt-4 mb-2">6.2 Active promotions</h3>
<p>A current list of active Promotions, along with their specific terms, is published at <Link to="/promotions" className="text-primary hover:underline">resolutionflow.com/promotions</Link>. Where there is any inconsistency between the general terms above and the specific terms of an individual Promotion, the specific terms control for that Promotion only.</p>
<p className="mt-3">If no Promotions are active, that page will state so.</p>
</section>
{/* Section 7 */}
<section id="changes">
<h2 className="text-xl font-semibold text-foreground mb-3">7. Changes to These Policies</h2>
<p>We may update these policies from time to time. Material changes will be announced by email to the address on file for the account at least fifteen (15) days before they take effect, and the &ldquo;Last updated&rdquo; date at the top of this document will be revised. Continued use of the Service after the effective date of a change constitutes acceptance of the updated policies. Customers who do not accept a material change may cancel under Section 4 before the effective date.</p>
</section>
{/* Section 8 */}
<section id="agreements">
<h2 className="text-xl font-semibold text-foreground mb-3">8. Relationship to Other Agreements</h2>
<p>These policies are part of, and incorporated into, the ResolutionFlow <Link to="/terms" className="text-primary hover:underline">Terms of Service</Link> and the <Link to="/privacy" className="text-primary hover:underline">Privacy Policy</Link>. Where these policies conflict with the Terms of Service or Privacy Policy, the Terms of Service control for matters of contract formation, liability, warranties, and dispute resolution; the Privacy Policy controls for matters of personal data handling; and these policies control for matters of billing, refunds, cancellation, customer service contact, legal restrictions, and Promotions.</p>
<p className="mt-3">For Enterprise customers operating under an executed order form or master services agreement, that agreement controls in the event of any conflict with these policies.</p>
</section>
<hr className="border-border" />
<p className="text-sm italic">
Questions about these policies? Email{' '}
<a href="mailto:billing@resolutionflow.com" className="text-primary hover:underline">billing@resolutionflow.com</a>{' '}
or use the contact form at{' '}
<Link to="/contact" className="text-primary hover:underline">resolutionflow.com/contact</Link>.
</p>
</div>
</div>
</div>
</>
)
}

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import { plansApi, type PublicPlanResponse } from '@/api/plans'
import { PageMeta } from '@/components/common/PageMeta'
import { MarketingFooter } from '@/components/common/MarketingFooter'
import { useAppConfig } from '@/hooks/useAppConfig'
import '@/styles/landing.css'
@@ -431,6 +432,8 @@ export function PricingPage() {
>
Built on Stripe + AWS · Encrypted in transit and at rest
</section>
<MarketingFooter />
</main>
</div>
)

View File

@@ -34,7 +34,7 @@ export default function PrivacyPage() {
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">5. Contact</h2>
<p>Questions about this policy? Email us at <a href="mailto:hello@resolutionflow.com" className="text-primary hover:underline">hello@resolutionflow.com</a>.</p>
<p>Questions about this policy? Email <a href="mailto:security@resolutionflow.com" className="text-primary hover:underline">security@resolutionflow.com</a> or visit our <Link to="/contact" className="text-primary hover:underline">contact page</Link>. Billing, cancellation, refund, and promotional terms are governed by our <Link to="/policies" className="text-primary hover:underline">Customer Policies</Link>.</p>
</section>
</div>
</div>

View File

@@ -0,0 +1,37 @@
import { Link } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
export default function PromotionsPage() {
return (
<>
<PageMeta title="Promotions" description="Active ResolutionFlow promotional offers and their terms." />
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto max-w-3xl px-6 py-16">
<Link to="/landing" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">&larr; Back to home</Link>
<h1 className="text-3xl font-bold font-heading mb-4">Promotions</h1>
<p className="text-muted-foreground mb-10">Last updated: May 7, 2026</p>
<div className="space-y-6 text-muted-foreground leading-relaxed">
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">Current promotions</h2>
<p>No promotions are currently active.</p>
<p className="mt-3">
Promotional offers, when running, will be listed on this page with their specific terms (eligible plans, duration, redemption rules, expiration). The general terms that apply to all promotions are described in{' '}
<Link to="/policies" className="text-primary hover:underline">Section 6 of our Customer Policies</Link>.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">Questions</h2>
<p>
Email{' '}
<a href="mailto:billing@resolutionflow.com" className="text-primary hover:underline">billing@resolutionflow.com</a>{' '}
for questions about a promotion you received by email, or to ask about upcoming offers.
</p>
</section>
</div>
</div>
</div>
</>
)
}

View File

@@ -39,7 +39,7 @@ export default function TermsPage() {
<section>
<h2 className="text-xl font-semibold text-foreground mb-3">6. Contact</h2>
<p>Questions about these terms? Email us at <a href="mailto:hello@resolutionflow.com" className="text-primary hover:underline">hello@resolutionflow.com</a>.</p>
<p>Questions about these terms? Email <a href="mailto:support@resolutionflow.com" className="text-primary hover:underline">support@resolutionflow.com</a> or visit our <Link to="/contact" className="text-primary hover:underline">contact page</Link>. Billing, cancellation, refund, and promotional terms are governed by our <Link to="/policies" className="text-primary hover:underline">Customer Policies</Link>.</p>
</section>
</div>
</div>

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

@@ -24,6 +24,9 @@ const PrivacyPage = lazyWithRetry(() => import('@/pages/PrivacyPage'))
const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage'))
const PricingPage = lazyWithRetry(() => import('@/pages/PricingPage'))
const ContactSalesPage = lazyWithRetry(() => import('@/pages/ContactSalesPage'))
const ContactPage = lazyWithRetry(() => import('@/pages/ContactPage'))
const PoliciesPage = lazyWithRetry(() => import('@/pages/PoliciesPage'))
const PromotionsPage = lazyWithRetry(() => import('@/pages/PromotionsPage'))
// Standalone auth pages
const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage'))
@@ -145,6 +148,21 @@ export const router = sentryCreateBrowserRouter([
element: page(ContactSalesPage),
errorElement: <RouteError />,
},
{
path: '/contact',
element: page(ContactPage),
errorElement: <RouteError />,
},
{
path: '/policies',
element: page(PoliciesPage),
errorElement: <RouteError />,
},
{
path: '/promotions',
element: page(PromotionsPage),
errorElement: <RouteError />,
},
{
path: '/login',
element: <LoginPage />,

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

@@ -54,7 +54,7 @@ export interface BillingStateApiResponse {
* Checkout / Customer-Portal session types
* ------------------------------------------------------------------------- */
export type CheckoutPlan = 'starter' | 'pro' | 'team' | 'enterprise'
export type CheckoutPlan = 'starter' | 'pro' | 'enterprise'
export type BillingInterval = 'monthly' | 'annual'
export interface CheckoutSessionRequest {