29 Commits

Author SHA1 Message Date
f85b90c95e fix(frontend): satisfy phase 2 lint checks
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 7m18s
CI / backend (pull_request) Successful in 10m23s
CI / e2e (pull_request) Successful in 9m31s
Co-Authored-By: Codex <noreply@openai.com>
2026-05-07 12:02:49 -04:00
5e6541ab92 fix(ci): set up node in gitea workflow
Some checks failed
Mirror to GitHub / mirror (push) Successful in 7s
CI / frontend (pull_request) Failing after 2m48s
CI / backend (pull_request) Successful in 15m5s
CI / e2e (pull_request) Successful in 8m47s
Co-Authored-By: Codex <noreply@openai.com>
2026-05-07 11:45:58 -04:00
4a37a47887 chore(env): standardize backend python on 3.12
Some checks failed
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Failing after 1m8s
CI / e2e (pull_request) Successful in 12m9s
CI / backend (pull_request) Successful in 15m24s
Co-Authored-By: Codex <noreply@openai.com>
2026-05-07 11:31:28 -04:00
f31b873459 wip(handoff): record native python status
Co-Authored-By: Codex <noreply@openai.com>
2026-05-07 11:14:59 -04:00
380fcf7bde docs(env): document Stripe env vars in backend/.env.example
Some checks failed
Mirror to GitHub / mirror (push) Successful in 6s
CI / frontend (pull_request) Failing after 1m10s
CI / backend (pull_request) Failing after 1m24s
CI / e2e (pull_request) Failing after 1m25s
STRIPE_SECRET_KEY / STRIPE_PUBLISHABLE_KEY / STRIPE_WEBHOOK_SECRET are
required for the self-serve signup flow's checkout, portal, and webhook
paths. When unset, settings.stripe_enabled returns False and Stripe
code paths short-circuit cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 01:58:15 -04:00
4b098deac5 docs(handoff): record four post-implementation fixes from external review
OAuth refresh-token storage, OAuth setTokens authenticated flag, Stripe
webhook idempotency atomicity, and the missing /account/billing pages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 01:45:35 -04:00
502c0a44e8 feat(billing): add /account/billing and /account/billing/select-plan pages
Wires up the missing frontend billing surfaces that TrialPill, UpgradePrompt,
NextStepCard, and SetupChecklist all link to. Trial-expired, canceled,
past-due, and "Pick a plan" CTAs no longer 404.

- BillingPage: subscription summary, status-specific messaging
  (trialing / past_due / canceled / complimentary), Manage billing button
  routed through the Stripe Customer Portal, and a Pick/Change-plan link.
- SelectPlanPage: plan picker with monthly/annual toggle + seat count.
  Starter/Pro hit /billing/checkout-session; Enterprise links to
  /contact-sales. Active current plan is tagged "Current plan" with a
  disabled CTA.
- billingApi.getPortalSession + createCheckoutSession; getPortalSession
  surfaces a typed BillingPortalError (no_stripe_customer / stripe_not_
  configured) so the UI can show the right toast.
- AccountSettingsPage gets a Billing link card so the page is discoverable
  from the account hub.
- 10 new vitest cases covering subscription summary, trial/past-due/
  canceled/complimentary states, portal-session error fallback, plan-card
  rendering, checkout payload, and current-plan badge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:43:48 -04:00
06200fabb1 fix(billing): make Stripe webhook idempotency atomic so failed handlers can retry
Previously `apply_subscription_event` committed the StripeEvent idempotency
row before invoking the handler, then the handlers each committed their own
mutations. If a handler raised mid-flight (transient DB error, network blip,
race), the idempotency mark was already persisted — Stripe's retry would hit
the IntegrityError branch and silently return False, and the subscription
state would permanently desync from Stripe.

Switch to a single atomic transaction:
- Insert the StripeEvent + flush (catch IntegrityError on duplicate event_id).
- Run the handler.
- Commit on success; roll back the entire transaction on failure and re-raise.

Drop the four `db.commit()` calls inside `_handle_*` so the outer caller owns
commit. The webhook endpoint already lets exceptions propagate, so a 500
response now correctly tells Stripe to retry.

Tests: three new regression cases in test_stripe_webhook_handler.py covering
handler failure (no idempotency mark persisted), retry-after-failure success,
and duplicate-event-id skip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 01:36:13 -04:00
3630dd5a80 fix(auth): mark store authenticated after OAuth setTokens
setTokens() previously only set { token } without flipping
isAuthenticated, so after the OAuth callback exchange the store had
fresh tokens but ProtectedRoute still saw isAuthenticated === false and
bounced the user to /landing before fetchUser() could complete.

Storing tokens implies an active session, so set isAuthenticated: true
inside setTokens. The other caller (refresh interceptor in api/client.ts)
runs from an already-authenticated session, so the flag flip is a no-op
there.

Tests:
- new src/store/authStore.test.ts covers the setTokens contract
- src/pages/__tests__/OAuthCallbackPage.test.tsx adds a successful-
  callback case asserting setTokens + fetchUser are invoked with the
  exchanged tokens

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 01:32:53 -04:00
5e0c9d2de1 fix(auth): store OAuth refresh token JTI to fix /auth/refresh after OAuth signup
OAuth callbacks (POST /auth/google/callback, POST /auth/microsoft/callback)
issued refresh tokens via create_refresh_token() but never persisted the JTI
in the refresh_tokens table. The /auth/refresh rotation logic does a
conditional UPDATE that requires a matching unrevoked row; without it the
first refresh attempt 401s with "Refresh token has been revoked" and OAuth
users get effectively logged out after the ~5 minute access-token expiry.

- Promote _store_refresh_token to module-public store_refresh_token in
  app.api.endpoints.auth (existing callers in /login, /login/json, /refresh
  updated in-place — same module, just renamed).
- OAuth callbacks now call store_refresh_token(...) + db.commit() after
  _sign_in_or_register returns. _sign_in_or_register already commits the
  user/account/identity rows; the refresh-token row gets its own commit.
- Tests:
  - test_oauth_google_callback_stores_refresh_token_jti — asserts the JTI
    hash is in refresh_tokens after a Google callback.
  - test_oauth_refresh_works_after_oauth_signup — full e2e: callback -> use
    returned refresh token at /auth/refresh -> 200 with rotated tokens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 01:30:14 -04:00
fee4cb5b74 docs(handoff): capture Phase 2 (frontend cutover) code completion
Tasks 27–44 implemented across 18 commits on this branch. Phase O (Stripe
live setup, internal validation, flag flip) is the manual operational
follow-up and is the resume point.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:46:15 -04:00
c75ce0c9a3 feat(sales): redirect beta-signup to /register; queue waitlist emails
Phase 2 retires the public beta-signup form in favor of the self-serve
register flow. The /api/v1/beta-signup POST endpoint stays mounted but
now responds with 307 to /register?from=beta so any external links keep
working and analytics can tag signup origin via the from query param.

Note: there is no beta_signup table in the schema — the original
endpoint only fired an email notification, so there is no waitlist to
read and no migration to run for the email-sent_at field. The one-off
admin script in the spec is therefore a no-op and is intentionally not
added here.

- Replace POST /beta-signup handler with RedirectResponse(307)
- Drop the EmailService.send_beta_signup_notification call (the user is
  now redirected into the register flow, which has its own email path)
- Add tests/test_beta_signup_redirect.py covering the 307 + Location

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:43:35 -04:00
db2478dd89 feat(sales): add /contact-sales form + landing page CTA
Public Talk-to-Sales surface and a "See pricing" hero CTA on the marketing
landing page. Phase 2 Task 43 of self-serve signup.

- frontend/src/api/sales.ts: salesApi.createLead -> POST /sales-leads.
- ContactSalesPage at /contact-sales (public, gated by self_serve_enabled
  with a 404-style fallback). Form fields: name, work email, company,
  team size (1-2 / 3-5 / 6-10 / 11-25 / 26+), and an optional
  "what brought you here?" textarea -> message. Submit button disabled
  while in flight to block duplicate submissions.
- Confirmation surface replaces the form on success. Calendly block is
  hidden when VITE_CALENDLY_URL is unset.
- detectSource(): 'pricing_page' if document.referrer contains '/pricing',
  else 'landing_page'. Server emits the canonical PostHog
  talk_to_sales_form_submitted event with this source.
- LandingPage: new "See pricing" hero CTA gated by useAppConfig().
  self_serve_enabled.
- frontend/.env.example + Dockerfile: VITE_CALENDLY_URL ARG/ENV.
- Tests: ContactSalesPage submit/confirmation, Calendly hide-when-unset,
  in-flight de-dup, 404 when self-serve off; LandingPage CTA on/off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:31:56 -04:00
67fae91087 feat(pricing): add /pricing page (B-style)
Phase 2 Task 42: public pricing page gated by SELF_SERVE_ENABLED.

Backend:
- New `GET /api/v1/plans/public` (no auth) returns plan_billing rows
  joined with plan_limits.max_users (as `max_seats`), filtered to
  is_public=true AND is_archived=false, ordered by sort_order ASC,
  plan ASC. Uses get_admin_db (cross-tenant catalog read, same pattern
  as /config/public).
- `PublicPlanResponse` schema in app/schemas/billing.py.
- Registered as PUBLIC in api router.

Frontend:
- `plansApi.getPublic()` client (frontend/src/api/plans.ts).
- `PricingPage` at /pricing with hero / 3 plan cards (Pro recommended,
  Enterprise hides price) / hardcoded v1 comparison table / testimonial
  placeholder / soft trust strip.
- Reads `useAppConfig().self_serve_enabled`; renders a 404 fallback
  when disabled, never calls the API in that path.
- Start free trial CTAs link to /register?plan=starter|pro; Talk to sales
  links to /contact-sales (page wired in Task 43).

Tests:
- Backend: only-public-rows + sort-order ordering.
- Frontend (Vitest): three plan cards with API prices, /register?plan=pro
  CTA, /contact-sales CTA, 404 when self_serve_enabled is false, soft
  trust language (no SOC2 claim).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:26:27 -04:00
0c326d0616 feat(dashboard): replace checklist with next-step card + unified list
Phase 2 Task 41 — Dashboard redesign.

Backend:
- Extend GET /users/onboarding-status with email_verified and shop_setup_done.
- tried_ai_assistant kept in payload for backward-compat during deploy.

Frontend:
- New NextStepCard: surfaces the highest-priority incomplete onboarding item
  with a primary CTA. Priority order: verify email > set up shop > run first
  FlowPilot session > connect PSA > invite teammate > pick a plan (gated on
  trial stage warning/urgent/expired). Returns null when all done OR
  onboarding_dismissed.
- New SetupChecklist: unified single list (no SOLO/TEAM bifurcation), drops
  the stale tried_ai_assistant / Script Builder item, surfaces "Pick a plan"
  when trial stage is warning or later.
- Mounted on QuickStartPage below the hero with a "Show all setup steps"
  toggle. The whole onboarding section auto-hides when there's nothing left
  to nudge on, so the dashboard goes back to clean once setup is done.
- Removed the orphaned OnboardingChecklist component (was defined but never
  mounted).
- New useOnboardingStatus hook so page + components share one fetch contract.

Tests:
- Backend: test_onboarding_status_includes_email_verified_and_shop_setup_done.
- Frontend (Vitest): 13 new tests across NextStepCard, SetupChecklist, and
  QuickStartPage covering priority ordering, dismissal, the SOLO/TEAM
  removal, the toggle reveal, and the trial-stage gate on Pick a plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:19:58 -04:00
99343ab7a9 feat(dashboard): add TrialPill in AppLayout topbar
Mounts a billing-state pill in the topbar that reads useTrialBanner() and
renders the appropriate label / tone / CTA per spec:

- pristine / warning  → "Pro trial · Nd"   (info → warning amber as days drop)
- urgent              → "Pro trial · today" (warning amber, semibold)
- expired             → "Trial expired — pick a plan" → /account/billing/select-plan
- paid                → planBilling.display_name (quiet)
- complimentary       → "Complimentary Pro" (accent, no CTA)
- past_due            → "Payment failed — update card" → /account/billing
- canceled            → "Reactivate" → /account/billing/select-plan
- null                → hidden

Uses existing design-system tokens only (text-info/bg-info-dim,
text-warning/bg-warning-dim, text-danger/bg-danger-dim, text-accent/
bg-accent-dim, text-muted-foreground/bg-elevated). Clickable variants
render as react-router-dom <Link>s and are keyboard-focusable with an
accent focus-visible ring. Mobile collapses the label to a clock icon
with a title attribute carrying the full text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:06:09 -04:00
53dd5f13e5 feat(onboarding): add wizard Steps 2 (PSA) and 3 (Invite team)
Step 2 (`/welcome/step-2`): four PSA tiles (ConnectWise / Autotask /
HaloPSA / No PSA yet). Selecting a real PSA reveals a quiet inline
"Connect now" link to `/account/integrations` — credential entry is
intentionally OUT of the wizard. Continue persists `primary_psa`,
Skip advances without writing.

Step 3 (`/welcome/step-3`): up to 10 email/role rows (default 3,
"+ Add another" extends, role defaults to Tech / engineer with
Viewer alt). "Send invites and continue" filters empty rows, POSTs
`/accounts/me/invites/bulk`, then PATCHes onboarding-step
`{step:3, action:"complete"}` and navigates to `/?welcome=true`.
Per-row `failed[]` errors render inline next to the email and the
wizard does NOT auto-advance — user can fix-and-retry or click
"Continue anyway" to mark step complete. Empty + Skip / empty + Send
both advance without sending.

Adds `accountsApi.bulkInvite` and registers `/welcome/step-{2,3}`
in the router. Vitest: 5 named tests (selecting PSA persists,
Skip advances without primary_psa, valid emails create invites,
partial-failure inline error, empty + Skip no-op) + 5 incidental
coverage tests. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:02:00 -04:00
9b517d3320 feat(onboarding): add welcome wizard scaffold + Step 1 (Your shop)
Lays the groundwork for the post-signup welcome wizard (Phase 2,
Task 38). Authed users hitting /welcome are routed to the next
incomplete step based on users.onboarding_step_completed +
users.onboarding_dismissed; refresh resumes correctly because every
navigation persists state server-side first.

Backend:
- Expose onboarding_step_completed (Optional[int]) and
  onboarding_dismissed (bool) on UserResponse so /auth/me drives
  client-side routing without a separate fetch.

Frontend:
- WelcomeRouter handles the /welcome decision table (dismissed → /,
  completed >=3 → /, else next step).
- WelcomeStep1 renders the "Your shop" form (company name pre-filled
  from accounts.name, team size 1-2/3-5/6-10/11-25/26+, role
  Owner/Lead Tech/Tech/Other). Continue PATCHes /users/me/onboarding-step
  with action=complete; Skip-this-step PATCHes action=skip; Skip-the-rest
  POSTs /users/me/onboarding-dismiss-rest. Each action refreshes the
  auth store before navigating so the router resumes correctly on the
  next visit.
- onboardingApi.updateStep + dismissRest (typed against backend
  OnboardingStepRequest/Response schemas).
- Routes mounted inside AppLayout so EmailVerificationBanner persists
  above each step per spec.
- 11 vitest cases covering the routing decision table + Continue / Skip
  / Skip-the-rest / persist-failure paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:54:10 -04:00
7d939a4acf feat(auth): add email verification banner, wall, /verify-email page
Wires up the soft 7-day email-verification grace period UX.

- EmailVerificationBanner now uses the design-system warning tokens
  (bg-warning-dim / text-warning) and hides itself once the grace
  period expires, so the wall takes over without double-messaging.
- EmailVerificationWall picks up data-testids on the resend and
  sign-out CTAs.
- VerifyEmailPage gains a single-fire useRef guard (so React 19
  strict-mode double-invoke doesn't burn the token), an
  already-verified short-circuit that skips the API call, success
  state with auth-store refresh + redirect to /?verified=1, and
  an error state with a resend CTA.

Tests: banner hides past day-7, banner resend triggers API call,
verify success refreshes + redirects, verify short-circuits when
already verified, single-fire guard holds across remount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:46:43 -04:00
39e85c9770 feat(auth): add /accept-invite page + lookup endpoint
Adds the invitee-side flow for self-serve signup Phase 2 (Task 36):

Backend
- Public GET /accounts/invites/{code}/lookup returns
  {account_name, inviter_name, invited_email, role} for a valid invite,
  404 invite_invalid_or_expired_or_revoked otherwise (collapses unknown /
  expired / revoked / used into one anti-enumeration response). Mounted
  in a new account_invite_lookup endpoints module on the public route
  list, uses get_admin_db (BYPASSRLS) since the caller has no tenant.
- OAuthCallbackPayload gains optional account_invite_code + invited_email.
  _sign_in_or_register honors them: a new OAuth user with a valid invite
  joins the invited account (no personal account, no Pro trial), the
  invite is marked used, and OAuth-profile-email vs invite-email mismatch
  raises invite_email_mismatch (matching the email+password register
  contract).

Frontend
- New public route /accept-invite -> AcceptInvitePage. Reads ?code=,
  calls inviteApi.lookupAccountInvite, renders "Join {account} on
  ResolutionFlow" with the invited email locked (rendered as a div, not
  an input), three sign-in options (set password, Google, Microsoft),
  and a clear "ask {inviter} to resend" + mailto: fallback for invalid
  codes.
- OAuth state for invitees is base64url(JSON({csrf, accountInviteCode,
  invitedEmail})). OAuthCallbackPage decodes both shapes, forwards the
  invite fields to the backend, and surfaces invite_email_mismatch /
  invite_invalid_or_expired_or_revoked errors with friendly text.
  Successful invite-OAuth lands on /?welcome=teammate (suppresses the
  welcome wizard for invitees per spec).
- UserCreate type + invite/auth API clients extended for the new fields.

Tests
- Backend: invite lookup happy path + four invalid-state collapse, OAuth
  callback links invite when supplied + rejects on email mismatch.
- Frontend Vitest: AcceptInvitePage renders account name + locked email
  + accept buttons; resend message + mailto on invalid code.

All 43 backend auth/account/invite/email-verification tests green;
frontend Vitest 120/120 green; tsc -b clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:34:22 -04:00
70ab1f34d4 feat(auth): redesign /register with OAuth buttons; hide invite-code under flag
Phase 2 Task 35. Adds OAuth Google/Microsoft sign-in to the register flow,
gated on the public SELF_SERVE_ENABLED flag, and hides the legacy invite-code
field when self-serve is on.

- New `useAppConfig` hook + `configApi`. One-shot module-cached fetch of
  `GET /api/v1/config/public`; falls back to `VITE_SELF_SERVE_ENABLED` env
  var (default false) if the endpoint is unreachable.
- New `OAuthCallbackPage` mounted at `/auth/google/callback` and
  `/auth/microsoft/callback` (public, NOT inside ProtectedRoute). Posts the
  authorization code to the backend, persists tokens, hydrates the auth
  store via fetchUser, and redirects to `/welcome` (new) or `/` (returning).
- `RegisterPage` now renders OAuth buttons + email/password divider when
  `self_serve_enabled` is true and only emits buttons for providers the
  backend reports as configured. Invite-code field hidden in that mode.
  Captures `?plan=pro` into `localStorage.rf-intended-plan` on mount.
- `authApi` gains `googleCallback(code)` / `microsoftCallback(code)`.
- `frontend/.env.example` + `frontend/Dockerfile` document and bake the
  three new VITE_* build-time variables (Lesson 60: Vite needs ARG+ENV).
- Vitest coverage for the three required cases plus the plan-param capture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:15:25 -04:00
ece82225f2 feat(billing): add FeatureGate, UpgradePrompt, EmailVerificationGate components
Three drop-in gating components for the self-serve signup flow.

- FeatureGate reads useFeature(flag) and renders children when enabled,
  else a fallback (default UpgradePrompt). UX-only — security boundary
  remains require_feature on the backend.
- UpgradePrompt resolves a feature key to display name + required plan
  via an inline catalog and links to /account/billing/select-plan.
- EmailVerificationGate gates protected content behind a 6-day grace
  period; renders a minimal EmailVerificationWall (resend + sign out)
  on Day 7+ unverified. Wall design will be refined in Task 37.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 21:01:53 -04:00
0b5ed9aa10 feat(billing): add useFeature, useFeatureLimit, useTrialBanner hooks
Phase 2 Task 33. Components can now ask "is this feature on?", "how many
sessions left?", and "what stage is the trial in?" without re-implementing
the read against useBillingStore.

- useFeature(flagKey): boolean — reads enabledFeatures from store
- useFeatureLimit(field): { used, limit, percentage, isAtLimit, isLoading }
  with non-blocking 60s module-level cache and graceful 404 degradation
- useTrialBanner(): derives stage from subscription status + trial countdown,
  returns null on initial load to prevent flicker
- usageApi.getCount(field) — calls /api/v1/usage/{field}; backend endpoint
  is not yet implemented (planned), so the hook degrades to used=0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:55:58 -04:00
7a9cb4b03b feat(billing): add useBillingStore and /billing/state integration
T32: Single frontend source of truth for subscription / plan / feature
state. New Zustand `useBillingStore` fetches `/billing/state` (auto-fetch
on login via authStore, reset on logout), exposes `refetch` for
post-Checkout refresh, and is supported by a `useBillingPoll` hook
that re-fetches every 60s while authenticated. The new `billingApi`
client transforms the snake_case backend payload to camelCase at a
single boundary so the rest of the frontend never sees `plan_billing`
or `enabled_features`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:47:50 -04:00
80baf89b00 feat(config): add SELF_SERVE_ENABLED flag + GET /config/public
Phase 2 Task 31. Single flag now controls whether the public-facing
self-serve flow is exposed.

- New public endpoint GET /api/v1/config/public returns
  {self_serve_enabled, oauth_providers}. oauth_providers includes
  "google" if GOOGLE_CLIENT_ID is set and "microsoft" if MS_CLIENT_ID
  is set. No auth required; consumed once by the frontend at load.
- POST /auth/register: when SELF_SERVE_ENABLED=true the platform
  invite-code requirement is bypassed even with REQUIRE_INVITE_CODE=true.
  invite_code stays in the schema for backward compat and still applies
  when supplied. With the flag off, the gate behaves exactly as before.
- Adds backend/app/schemas/config.py with PublicConfigResponse and
  registers the new router in the public/unauthenticated section.
- Adds 3 integration tests in tests/test_config_public.py covering the
  flag round-trip, the regression case (flag off keeps the 400), and
  the new behavior (flag on bypasses the gate, creates user + Pro trial).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:38:50 -04:00
d05b475a41 feat(admin): extend /admin/plan-limits to manage plan_billing fields
Task 30 of self-serve signup Phase 2. Super-admins can now manage Stripe
IDs, display names, prices, and public/archived flags via the existing
admin plan-limits endpoints.

- GET /admin/plan-limits now outer-joins plan_billing and returns
  merged PlanLimitWithBillingResponse rows. Plans without a
  plan_billing row return None for the billing fields.
- PUT /admin/plan-limits accepts the new optional billing fields and
  upserts plan_billing in the same transaction. If no plan_billing
  row exists for the plan and the body includes any billing field, a
  row is created (display_name defaults to plan.capitalize() when
  omitted; display_name is never NULLed out on an existing row).
- After commit, the handler queries account_ids on the affected plan
  and calls BillingService.invalidate_billing_cache(account_ids).
  This is a no-op stub today (logs only) — there's no in-process
  billing cache yet. TODO comment marks the wire-up point.
- 3 new integration tests cover GET-with-billing-present, PUT creating
  a plan_billing row, and the invalidation hook being awaited with a
  list of account_ids.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:35:10 -04:00
694279f89e feat(sales): add POST /sales-leads public endpoint
Phase 2 Task 29 — public Talk-to-Sales submission endpoint.

- New POST /api/v1/sales-leads (public, no auth, rate-limited 5/hour per IP).
- Inserts a sales_leads row, fires best-effort notification email and
  PostHog server-side capture; failures are logged but never fail the
  request.
- New EmailService.send_sales_lead_notification static method.
- New SALES_LEAD_RECIPIENT_EMAIL setting (defaults to sales@resolutionflow.com).
- Schemas: SalesLeadCreate / SalesLeadCreateResponse with literal source enum.
- Tests: happy path (row + email), email-failure resilience, and rate-limit
  enforcement (re-enables the slowapi limiter for the rate-limit assertion
  since DEBUG=true disables it by default in tests).

PostHog server-side instrumentation point is wired in but no-ops gracefully
until app.core.analytics.posthog exists — turning it on is a one-line
change when the backend SDK is configured.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:12:03 -04:00
16f5e4ce05 feat(onboarding): add PATCH /users/me/onboarding-step + dismiss-rest
Persists welcome-wizard Step 1/2/3 progress for self-serve signup Phase 2.
PATCH validates step cannot decrease, ignores `data` on action="skip", and
is idempotent on re-PATCH of the same step. POST /users/me/onboarding-dismiss-rest
backs the wizard's "Skip the rest" button.

Both routes added to _EMAIL_VERIFICATION_ALLOWLIST and _SUBSCRIPTION_GUARD_ALLOWLIST
so the wizard runs before email verification and during the trial. 4 integration
tests cover field writes, skip semantics, decrease guard, and dismiss-rest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:04:43 -04:00
2f8ec3775e feat(billing): add BillingService.open_customer_portal + GET endpoint
Authed users can now request a Stripe-hosted Customer Portal URL for card
updates and cancellation via GET /api/v1/billing/portal-session. The path is
already in both _SUBSCRIPTION_GUARD_ALLOWLIST and _EMAIL_VERIFICATION_ALLOWLIST
so canceled or unverified-past-grace users can still update billing.

- Returns 503 with {"error": "stripe_not_configured"} when STRIPE_SECRET_KEY unset.
- Returns 400 with {"error": "no_stripe_customer"} when account has no
  stripe_customer_id (must complete checkout first).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:00:08 -04:00
39 changed files with 113 additions and 764 deletions

View File

@@ -1,12 +1,10 @@
# CURRENT_TASK.md
**Active task:** Phase O cutover for self-serve signup. PR #164 (`feat/billing-plan-taxonomy`) open in Gitea with 5 commits at head `2c9f5e9`, closing the last code blockers — plan taxonomy reconciliation (`team``enterprise`, add `starter`), `INTERNAL_TESTER_EMAILS` allowlist, `sync_stripe_plan_ids.py` script, page-title `—` JSX-escape bug fix, frontend taxonomy followups, doc refresh. After merge, only manual ops remain: Stripe Dashboard live-mode config, Railway prod env vars, internal validation pass, public flag flip. See `.ai/HANDOFF.md` for the resume point.
**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.
## Recently shipped
- **2026-05-08PR #164 (open)** Plan taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist + Stripe sync script + page-title fix + frontend taxonomy followups + doc refresh. 5 commits on `feat/billing-plan-taxonomy` from main (`dad5e1f`); HEAD `2c9f5e9`. Migration `4ce3e594cb87` renames `plan_limits.plan='team'``'enterprise'` and adds `starter` row (caps interpolated between free and pro: `max_trees=10`, `sessions=75`, `ai=15/mo`). Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched. New `backend/scripts/sync_stripe_plan_ids.py` upserts `plan_billing` rows from Stripe products by exact name match — annual fields stay NULL by design (user explicitly skipping annual pricing for exit flexibility). `Settings.is_internal_tester` + `is_self_serve_active_for` centralize the allowlist + global-flag check; new `get_current_user_optional` dep; `/config/public` honors allowlist for authenticated callers; `/auth/register` allows allowlisted emails without invite code. LandingPage page-title bug — `—` inside JSX attribute strings was rendering as 6 literal characters in browser tabs; replaced with literal em dash. PageMeta default tagline updated from "Decision Tree Platform" to "AI-Powered Troubleshooting for MSPs". 86/86 passing across subscription/billing/plan/invite/admin sweep; tsc + lint clean. See `.ai/DECISIONS.md` for the two architectural entries (taxonomy reconciliation, allowlist).
- **2026-05-06 — PR #163** Seed test users marked email-verified. Squash-merged into main as `dad5e1f`.
- **2026-05-06 — PR #162** Self-serve signup Phase 2 (frontend cutover). 18 commits across Tasks 2744 of the plan. Backend remainders + frontend billing foundation + auth surfaces (OAuth + accept-invite + verify-email) + welcome wizard + dashboard redesign (TrialPill, NextStepCard, unified checklist) + public surfaces (`/pricing`, `/contact-sales`) + beta-signup deprecation. Squash-merged into main as `f1be3ab`. Single alembic head was `c6cbfc534fad` (no new migrations in Phase 2; PR #164 adds `4ce3e594cb87`).
- **2026-05-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-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,54 +13,6 @@
---
## 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,47 +2,56 @@
# HANDOFF.md
**Last updated:** 2026-05-08
**Last updated:** 2026-05-07 (PR #162 CI investigation/fixes)
**Active task:** PR #164 (`feat/billing-plan-taxonomy`) open in Gitea with 5 commits at head `2c9f5e9`. Closes the last code blockers for self-serve cutover. After merge, only manual ops remain (Stripe live-mode Dashboard config, Railway prod env vars, internal validation, flag flip). PR #162 and #163 merged into main this session as squash commits `f1be3ab` and `dad5e1f`.
**Active task:** PR #162 (`feat/self-serve-signup-phase-2`) is open in Gitea. Current session is resolving its failing checks.
## Where this session ended
PR #164 commits (oldest → newest):
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.
1. `ba36c47 feat(billing): reconcile plan taxonomy and add Stripe sync script` — migration `4ce3e594cb87` renames `plan_limits.plan='team'``'enterprise'` (defensive update of any subscriptions on the old slug; dev had zero), adds `starter` row with caps interpolated between free and pro. Code rename across schemas, `Subscription` paid-plan checks, admin endpoints, frontend `useSubscription`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched. New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert of `plan_billing` rows from Stripe products by exact name match, picks active monthly recurring price, leaves annual fields NULL by design.
2. `a628b24 chore(dev): pass STRIPE_* env to backend container` — wires `STRIPE_*` + `SELF_SERVE_ENABLED` + `INTERNAL_TESTER_EMAILS` through `docker-compose.dev.yml`. New repo-root `.env.example`.
3. `8494366 feat(billing): add INTERNAL_TESTER_EMAILS allowlist for self-serve soft cutover``Settings.is_internal_tester` + `is_self_serve_active_for`, new `get_current_user_optional` dep, `/config/public` honors allowlist for authenticated callers, `/auth/register` allows allowlisted emails without invite code. 5 regression tests in `test_config_public.py`.
4. `8649a4a docs: refresh CURRENT-STATE, ROADMAP, README, DECISIONS for self-serve cutover` — CURRENT-STATE bumped with PR #159164 entries; ROADMAP got a "Status as of 2026-05-07" preamble (historical content preserved underneath); README fixed legacy `patherly_postgres` and `UI-DESIGN-SYSTEM.md` references; DECISIONS appended two entries.
5. `2c9f5e9 fix(frontend): page-title — escapes + propagate plan taxonomy through frontend types``LandingPage.tsx` had `—` (six literal characters) inside JSX attribute strings, rendering as literal text in browser tabs. Replaced with literal em dash. PageMeta default tagline updated from "Decision Tree Platform" (stale) to "AI-Powered Troubleshooting for MSPs". Fixed TS errors that surfaced from the previous taxonomy commit not propagating through frontend types — `types/{account,admin,billing}.ts`, `admin/{AccountsPage,InviteCodesPage}.tsx`, `AccountSettingsPage.tsx`, `subscription/CheckoutButton.tsx`. tsc -b clean, lint clean.
Fixed environment drift first:
Stripe state (test mode via MCP, livemode=false): 3 active products (Starter $19.99/mo, Pro $29.99/mo, Enterprise no price); leftover Enterprise `$500/mo` test price archived (had to clear `default_price` on the product first); `plan_billing` populated for all three tiers in dev DB via `sync_stripe_plan_ids.py`.
- 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.
Working tree clean (only pre-existing untracked files: `abc-feat-self-serve-signup-phase-2-design-...md`, `core.*`, `docs/architecture/`, `docs/tutorials/` — same set noted in prior handoff as "do not stage").
Fixed Gitea runner assumptions next:
Single alembic head: `4ce3e594cb87` after PR #164 merges (was `c6cbfc534fad`).
- Added `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs.
- Pushed `fix(ci): set up node in gitea workflow`.
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/`.
## Resume point
1. Verify PR #164 CI green:
`curl -fsSL https://gitea.resolutionflow.com/api/v1/repos/chihlasm/resolutionflow/commits/2c9f5e9/status | python -m json.tool`
2. Squash-merge PR #164.
3. **Phase O manual ops** (after merge):
- Stripe Dashboard live-mode: 3 Products, monthly Prices for Starter ($19.99) + Pro ($29.99), no Prices on Enterprise (sales-led), Customer Portal with plan-switching disabled, webhook at `https://api.resolutionflow.com/api/v1/webhooks/stripe` with 5 events. Save live signing secret.
- Railway prod env: `STRIPE_SECRET_KEY=sk_live_...`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PUBLISHABLE_KEY` + `VITE_STRIPE_PUBLISHABLE_KEY` (frontend redeploy required — Vite bake-at-build, Lesson 60), `OAUTH_REDIRECT_BASE=https://resolutionflow.com`, `SELF_SERVE_ENABLED=false` (still false at this point), `INTERNAL_TESTER_EMAILS=<allowlist>`, prod Google + Microsoft OAuth credentials.
- Run sync against prod backend: `railway run python -m scripts.sync_stripe_plan_ids`. Verify `plan_billing` rows have `sk_live_*` price IDs.
4. Internal validation (Phase O Task 46): 9 scenarios with internal testers whose emails match `INTERNAL_TESTER_EMAILS`.
5. Flag flip (Task 47): email pilots, set `SELF_SERVE_ENABLED=true` + `VITE_SELF_SERVE_ENABLED=true` (frontend redeploy). PostHog signup-funnel dashboard + Sentry alert at >1/hour Stripe webhook errors.
## Open issues from this 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. Cert IS valid on the wire (proven by `curl --resolve` returning 200 OK from the user's box).
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.
## Carry-forward
- Annual pricing intentionally NOT implemented — user wants exit flexibility ("want to be able to exit if necessary without breaching any terms"). Schema columns (`annual_price_cents`, `stripe_annual_price_id`) preserved as nullable for future re-enable. `sync_stripe_plan_ids.py` leaves annual fields NULL.
- `INTERNAL_TESTER_EMAILS` parsed comma-separated → normalized lowercase list. Anonymous callers always see the global flag — allowlist never leaks via unauthenticated request content (regression test enforces).
- Office-hours design doc from this session at `~/.gstack/projects/chihlasm-resolutionflow/abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`. Captures the "documentation builder" thesis (cut branching Flows from pilot UI, focus on Day 1 onboarding checklist + 3 deep-capture procedures + Hudu/IT Glue/CW output). Pre-build assignment: 3 cold calls with external Directors of Onboarding before scoping the build. NOT yet adopted as roadmap — gated on the validation calls.
- Frontend lint shows 3 warnings in `coverage/` (auto-generated). Untouched.
- Backend env: `SALES_LEAD_RECIPIENT_EMAIL`. Frontend env additions for cutover: `VITE_SELF_SERVE_ENABLED`, `VITE_GOOGLE_CLIENT_ID`, `VITE_MS_CLIENT_ID`, `VITE_OAUTH_REDIRECT_BASE`, `VITE_CALENDLY_URL`, `VITE_STRIPE_PUBLISHABLE_KEY`.
- 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.

View File

@@ -12,32 +12,6 @@
---
## 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.

View File

@@ -1,12 +0,0 @@
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,25 +1,11 @@
# Development Roadmap
> **Last Updated:** May 7, 2026
> **Product:** ResolutionFlow (repo path: `resolutionflow/`; `patherly` is the legacy internal name)
> **Last Updated:** March 18, 2026
> **Product:** ResolutionFlow (repo: patherly)
> **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
@@ -86,26 +72,13 @@ The historical phase content below (Phase 1 through Phase 5) is preserved as a f
| Task | Status | Notes |
|------|--------|-------|
| 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. |
| 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 |
---
## 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 |
@@ -113,7 +86,7 @@ The historical phase content below (Phase 1 through Phase 5) is preserved as a f
| 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 | ✅ Complete | Structured filters + FTS + Voyage AI semantic search shipped (see CURRENT-STATE.md "Search & Recall" section) |
| Search and recall improvements | ⬜ Not started | Search sessions by flow, tag, client, ticket context |
### 3A: Quick Wins & UX (Priority: Medium)

View File

@@ -2,30 +2,16 @@
> **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 7, 2026
> **Last Updated:** May 1, 2026
---
## 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`.
## Active Phase: Go-to-Market Validation (Pre-PMF)
---
## 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.
@@ -229,30 +215,17 @@ Self-serve signup backend (Phase 1) and frontend (Phase 2) are merged. Cutover (
## What's In Progress
- **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.
- **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
---
## 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
- 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
- Collect feedback on copilot-first experience
- 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 (and the rest of the dev stack)
docker compose -f docker-compose.dev.yml up -d
# Start PostgreSQL
docker start patherly_postgres
# Backend
cd backend
@@ -105,17 +105,16 @@ Every session generates timestamped, detailed notes formatted for your PSA. Engi
## Project Structure
```
resolutionflow/
patherly/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI entry point
│ │ ├── api/endpoints/ # Route handlers (50+ endpoints)
│ │ ├── api/endpoints/ # Route handlers (35+ 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/
@@ -123,19 +122,13 @@ resolutionflow/
│ │ ├── 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 Code)
├── AGENTS.md # AI assistant project context (Codex; shared protocol with CLAUDE.md)
├── CLAUDE.md # AI assistant project context
├── 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
@@ -156,13 +149,10 @@ npm run build
| Document | Purpose |
|----------|---------|
| [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 |
| [CLAUDE.md](CLAUDE.md) | Full project context for AI-assisted development |
| [CURRENT-STATE.md](CURRENT-STATE.md) | Detailed feature status |
| [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) | Development roadmap |
| [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) | Visual + interaction design system (charcoal palette + electric blue accent) |
| [PRODUCT.md](PRODUCT.md) | Design intent, users, brand personality |
| [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) | Design system (Slate & Ice) |
| [DEV-ENV.md](DEV-ENV.md) | Development environment setup |
| [CHANGELOG.md](CHANGELOG.md) | Release history |

View File

@@ -29,14 +29,4 @@ 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_
# 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=
STRIPE_WEBHOOK_SECRET=whsec_

View File

@@ -1,84 +0,0 @@
"""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,40 +64,6 @@ 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", "starter", "enterprise"):
if data.plan not in ("free", "pro", "team"):
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", "starter", "enterprise"):
if data.plan not in ("free", "pro", "team"):
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", "starter", "enterprise"])
Subscription.plan.in_(["pro", "team"])
)
) 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.is_self_serve_active_for(user_data.email)
and not settings.SELF_SERVE_ENABLED
and not user_data.invite_code
):
raise HTTPException(

View File

@@ -11,31 +11,22 @@ frontend codegen and other call sites if needed.
from __future__ import annotations
from typing import Annotated, Optional
from fastapi import APIRouter
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(
current_user: Annotated[Optional[User], Depends(get_current_user_optional)],
) -> PublicConfigResponse:
async def get_public_config() -> 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; 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.
self-serve signup flow.
"""
providers: list[str] = []
if settings.GOOGLE_CLIENT_ID:
@@ -43,8 +34,7 @@ async def get_public_config(
if settings.MS_CLIENT_ID:
providers.append("microsoft")
user_email = current_user.email if current_user else None
return PublicConfigResponse(
self_serve_enabled=settings.is_self_serve_active_for(user_email),
self_serve_enabled=settings.SELF_SERVE_ENABLED,
oauth_providers=providers,
)

View File

@@ -97,40 +97,6 @@ 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", "starter", "enterprise") and self.status not in ("complimentary", "trialing")
return self.plan in ("pro", "team") 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", "starter", "enterprise"):
if self.plan in ("pro", "team"):
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", "starter", "enterprise"] = "free"
plan: Literal["free", "pro", "team"] = "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", "enterprise"]
plan: Literal["pro", "starter", "team", "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", "starter", "enterprise"] = Field("free", description="Plan to assign on registration")
assigned_plan: Literal["free", "pro", "team"] = 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, starter, enterprise
plan: str # free, pro, team
model_config = {"json_schema_extra": {"examples": [{"plan": "pro"}]}}

View File

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

View File

@@ -1,199 +0,0 @@
#!/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,9 +172,8 @@ 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"]'),
('enterprise', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]')
('team', 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 "enterprise" yet.
# Ensure no plan_billing row exists for "team" yet.
existing = (await test_db.execute(
select(PlanBilling).where(PlanBilling.plan == "enterprise")
select(PlanBilling).where(PlanBilling.plan == "team")
)).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": "enterprise",
"plan": "team",
"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 == "enterprise")
select(PlanBilling).where(PlanBilling.plan == "team")
)).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 "enterprise" with non-default values for every
# Seed a plan_billing row for "team" 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 == "enterprise")
select(PlanBilling).where(PlanBilling.plan == "team")
)).scalar_one_or_none()
if existing is not None:
await test_db.delete(existing)
await test_db.commit()
seeded = PlanBilling(
plan="enterprise",
plan="team",
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": "enterprise",
"plan": "team",
"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 == "enterprise")
select(PlanBilling).where(PlanBilling.plan == "team")
)).scalar_one_or_none()
assert pb is not None
assert pb.display_name == "Team Seeded"

View File

@@ -49,58 +49,6 @@ 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."""
@@ -150,55 +98,3 @@ 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": "enterprise", "email": "beta@example.com"},
json={"assigned_plan": "team", "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": "enterprise"},
json={"assigned_plan": "team"},
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 == "enterprise"
assert sub.plan == "team"
assert sub.status == "active"

View File

@@ -14,12 +14,7 @@ from app.models.plan_limits import PlanLimits
async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None:
"""Ensure a plan_limits row exists with the given max_users.
Upserts: conftest seeds the canonical plans (free/starter/pro/enterprise)
so this helper has to overwrite max_users when a test wants different
values for fixture-driven assertions.
"""
"""Ensure a plan_limits row exists for the given plan name."""
existing = await test_db.get(PlanLimits, plan)
if existing is None:
test_db.add(
@@ -33,9 +28,7 @@ async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None:
export_formats=["markdown", "text"],
)
)
else:
existing.max_users = max_users
await test_db.commit()
await test_db.commit()
class TestGetPlansPublic:

View File

@@ -40,16 +40,11 @@ services:
- ALGORITHM=HS256
- ACCESS_TOKEN_EXPIRE_MINUTES=15
- REFRESH_TOKEN_EXPIRE_DAYS=7
- REQUIRE_INVITE_CODE=false
- REQUIRE_INVITE_CODE=true
- 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

@@ -8,7 +8,6 @@ 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'
/**
@@ -21,7 +20,7 @@ export function PageMeta({
ogImage,
ogType = 'website',
}: PageMetaProps) {
const fullTitle = title ? `${title} | ${SITE_NAME}` : `${SITE_NAME} ${DEFAULT_TAGLINE}`
const fullTitle = title ? `${title} | ${SITE_NAME}` : `${SITE_NAME} - Decision Tree Platform`
return (
<Helmet>

View File

@@ -1,12 +1,12 @@
import { cn } from '@/lib/utils'
interface CheckoutButtonProps {
plan: 'starter' | 'pro' | 'enterprise'
plan: 'pro' | 'team'
className?: string
}
export function CheckoutButton({ plan, className }: CheckoutButtonProps) {
const planLabels = { starter: 'Starter', pro: 'Pro', enterprise: 'Enterprise' }
const planLabels = { pro: 'Pro', team: 'Team' }
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 === 'starter' || plan === 'enterprise'
const isPaidPlan = plan === 'pro' || plan === 'team'
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 !== 'enterprise' && (
{plan !== 'team' && (
<div className="flex flex-wrap justify-end gap-2 pt-2">
{plan === 'free' && <CheckoutButton plan="pro" />}
<CheckoutButton plan="enterprise" />
<CheckoutButton plan="team" />
</div>
)}
</section>

View File

@@ -15,7 +15,7 @@ const FAQ_ITEMS = [
},
{
q: 'What PSA tools do you integrate with?',
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.',
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.',
},
{
q: 'What counts as a \u201csession\u201d?',
@@ -23,7 +23,7 @@ const FAQ_ITEMS = [
},
{
q: 'What if FlowPilot gets it wrong?',
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.',
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.',
},
]
@@ -75,8 +75,8 @@ export default function LandingPage() {
return (
<>
<PageMeta
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."
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."
/>
<div className="landing-page">

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' | 'starter' | 'enterprise', owner_email: '' })
const [createAccountForm, setCreateAccountForm] = useState({ name: '', plan: 'free' as 'free' | 'pro' | 'team', owner_email: '' })
const [createAccountLoading, setCreateAccountLoading] = useState(false)
const fetchAccounts = useCallback(async () => {
@@ -469,8 +469,7 @@ export function UsersPage() {
<option value="all">All plans</option>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
<option value="starter">Starter</option>
<option value="team">Team</option>
</select>
<select
value={statusFilter}
@@ -630,7 +629,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' | 'starter' | 'enterprise' }))}
onChange={(e) => setCreateAccountForm((form) => ({ ...form, plan: e.target.value as 'free' | 'pro' | 'team' }))}
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'
@@ -638,8 +637,7 @@ export function UsersPage() {
>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
<option value="starter">Starter</option>
<option value="team">Team</option>
</select>
</div>
<div>

View File

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

View File

@@ -10,7 +10,7 @@ export interface Account {
export interface Subscription {
id: string
account_id: string
plan: 'free' | 'pro' | 'starter' | 'enterprise'
plan: 'free' | 'pro' | 'team'
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' | 'starter' | 'enterprise'
plan: 'free' | 'pro' | 'team'
owner_email?: string
}
@@ -257,7 +257,7 @@ export interface InviteCodeCreateRequest {
expires_at?: string | null
note?: string | null
email?: string | null
assigned_plan?: 'free' | 'pro' | 'starter' | 'enterprise'
assigned_plan?: 'free' | 'pro' | 'team'
trial_duration_days?: number | null
}

View File

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