76 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
f918b766b0 feat: self-serve signup backend (Phase 1) (#161)
All checks were successful
CI / frontend (push) Successful in 5m16s
Mirror to GitHub / mirror (push) Successful in 6s
CI / e2e (push) Successful in 10m22s
CI / backend (push) Successful in 10m55s
2026-05-06 23:46:34 +00:00
fbb41e789c docs(handoff): capture Phase 1 backend completion + followups
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m0s
CI / backend (pull_request) Successful in 11m15s
CI / e2e (pull_request) Successful in 10m4s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
97d36dd400 test(kb-accelerator): downgrade kb_setup user to free plan
The kb_setup fixture asserts free-plan quota numbers (lifetime_conversions_limit=3),
but Phase 1 conftest seeds test_user on Pro. Downgrade explicitly inside kb_setup
to preserve the original test intent without affecting other suites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
f26f468878 feat(billing): pilot user backfill — set existing accounts to complimentary
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
79942c3fd3 feat(billing): add GET /billing/state aggregating subscription + plan + features
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
4768ae0648 feat(invites): add bulk-create and soft-revoke invite endpoints
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
e54d6c586a feat(invites): wire EmailService.send_account_invite_email into create handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
86893562b9 feat(auth): auto-send verification email on register; enforce invite email match
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
b0708ed650 feat(auth): guard login/password paths against OAuth-only users
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
2ef2350de7 feat(auth): add Microsoft OAuth callback
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
f4606f073a feat(auth): add Google OAuth callback with oauth_identities linking
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
9b709488d9 feat(billing): extend Stripe webhook stub with concrete event handlers
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
18180bc57f feat(billing): apply_subscription_event with stripe_events idempotency
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
f683bb5720 feat(billing): add /billing/checkout-session via BillingService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
9851d56633 feat(billing): add BillingService.start_trial; wire into /auth/register
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
519c7eb5ce feat(deps): add require_verified_email_after_grace guard
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
9ec208f6e7 feat(deps): add require_active_subscription guard with allowlist
Mounts on Pro routers (trees, sessions, scripts, FlowPilot, etc.) and
returns 402 with structured detail when an account's subscription is
missing or locked. Allowlist bypasses billing/account/auth flows so
users can recover from a lapsed subscription.

Conftest now seeds a default Pro/active Subscription on test_user and
test_admin (delete-then-insert because the register endpoint already
creates a free/active sub by default). Two existing tests adapted to
the new seeded plan; tenant-isolation tests seed Subscription rows for
the accounts they create directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
cfe0e6cae6 refactor(deps): remove trial auto-downgrade; expiry now non-mutating per spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
e3f5ed4985 feat(billing): add complimentary status, fix is_paid, add has_pro_entitlement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
5105eaf529 feat(billing): add sales_leads and stripe_events tables
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
974b188c1e feat(billing): add plan_billing sibling table for Stripe + catalog metadata
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
a28b635b19 feat(invites): add revoked_at + email_sent_at to account_invites
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
50e7763380 feat(onboarding): add accounts.team_size_bucket and primary_psa for wizard
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
b3ed76c203 feat(onboarding): add users.role_at_signup and onboarding_step_completed
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
453ba3fefc feat(auth): make users.password_hash nullable for OAuth-only accounts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
143c979975 feat(auth): add oauth_identities table for Google/Microsoft sign-in
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
ab0d40c1e2 docs(plan): self-serve signup & onboarding implementation plans
Adds two phase plans alongside the spec at
docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md:

- Phase 1 (backend foundation, 26 tasks across 8 sub-phases A-H):
  schema migrations, subscription model + new guards, BillingService,
  Stripe webhook handler extension, OAuth callbacks, email verification
  auto-send + email-match enforcement, account-invite extensions,
  GET /billing/state, pilot user backfill. Step-by-step granularity
  with full code blocks per writing-plans skill.

- Phase 2 (frontend + cutover, 21 tasks across 7 sub-phases I-O):
  Phase-1-deferred endpoints, useBillingStore + hooks + gating
  components, register redesign + OAuth buttons + accept-invite,
  welcome wizard, dashboard redesign, pricing page + contact-sales,
  beta-signup deprecation, cutover. Higher-altitude — defines
  contracts, acceptance criteria, integration tests; leaves
  component-detail decisions to implementer.

Each phase ends in a mergeable PR. Cutover is gated behind
SELF_SERVE_ENABLED + VITE_SELF_SERVE_ENABLED. Execution deferred to
a future session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:30 -04:00
278b9342b4 docs(spec): self-serve signup & onboarding design
Adds docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md.
Six-section design for opening ResolutionFlow to public self-serve registration
with a 14-day reverse trial on Pro, Stripe-backed billing, sales-assist
Enterprise lane, and a hybrid welcome wizard + dashboard onboarding.

Reuses existing infrastructure (subscriptions, plan_limits, feature_flags,
plan_feature_defaults, account_feature_overrides, account_invites,
email_verification_tokens, /admin/plan-limits, /admin/feature-flags,
/accounts/me/transfer-ownership, /webhooks/stripe stub). New schema is
intentionally small: oauth_identities, plan_billing (sibling to plan_limits),
sales_leads, stripe_events, plus column additions for OAuth identity model
nullability, wizard step state, and pilot-account complimentary status.

Replaces deps.py:109 trial auto-downgrade with a non-mutating computed
expiry check enforced by a new require_active_subscription dep. Adds a
sibling require_verified_email_after_grace dep to enforce the 7-day email
verification grace at the API layer (frontend wall is UX over the same rule).

Defers promo codes from v1. No new combined /admin/plans surface — existing
admin endpoints handle plan/feature configuration with extended response
shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 19:14:29 -04:00
a8b22cfa0b feat: post-PR-159 UI cleanup — sidebar IA + account redesign (#160)
All checks were successful
CI / frontend (push) Successful in 5m11s
Mirror to GitHub / mirror (push) Successful in 6s
CI / backend (push) Successful in 10m19s
CI / e2e (push) Successful in 10m31s
2026-05-06 23:14:16 +00:00
b544a7a462 test(e2e): update account page heading assertion to match redesign
All checks were successful
Mirror to GitHub / mirror (push) Successful in 7s
CI / frontend (pull_request) Successful in 5m14s
CI / backend (pull_request) Successful in 9m57s
CI / e2e (pull_request) Successful in 10m21s
8612042 dropped the static "Account Management" heading in favor of the
account name (rendered as a dynamic h1). Switch the smoke test to the
"Settings" SectionLabel — a stable h2 that survives the redesign.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 18:54:53 -04:00
07a3f01184 fix(qa): ISSUE-001 — fall back to members.length when usage.user_count is missing
Some checks failed
Mirror to GitHub / mirror (push) Successful in 12s
CI / frontend (pull_request) Successful in 5m30s
CI / e2e (pull_request) Failing after 11m2s
CI / backend (pull_request) Successful in 14m47s
The /subscription endpoint returns usage as {tree_count, session_count_this_month}
without user_count, so the Seats UsageRow rendered as " / ∞" (blank current value).
The TS type declared user_count: number, hiding this API/type drift; the old
card-stack design hid it visually because each stat had its own border. The new
flat layout surfaced the gap.

Owners get a fallback to members.length (already fetched). Non-owners can't
fetch members and don't need seat-count info, so the row hides entirely for
them. Verified live: owner now sees Seats 2 / ∞.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 01:02:44 -04:00
86120423da refactor(account): redesign settings index, drop card stack
The index page had ~12 distinct card surfaces with three places of
nested cards-inside-cards, against PRODUCT.md's "elevation = lighter
surface + border" + "nested cards are always wrong" rules. Branding
appeared twice, Display Code lived in Identity but does invite work,
and Preferences got a full card for one dropdown.

Single column, max-w-3xl, no card chrome. Sections separated by
border-t rules + mono-uppercase section labels (existing house style):

- Header: inline-editable name + plan/status/owner/member-count info
  line. No card.
- Plan & usage: renewal date right-aligned in section header, three
  thin progress rows replace the 4-card usage stat grid, upgrade
  CTAs right-aligned at bottom.
- People (owner-only): invite form, unified members + pending invites
  list, display code as a quiet "share to invite during signup" line.
  Non-owners see a one-line "managed by your admin" instead of a card.
- Settings: dense route list (icon + title + summary + status pill +
  chevron). Profile above a thin divider; team-admin rows below,
  owner-gated. Branding row carries the Included/Plan-gated pill.
  Support & Feedback as a dim link at the bottom.
- Account actions: plain rows. Owner: Transfer + Delete. Non-owner:
  Leave. Destructive labels colored, no red box-of-doom.

Drops: Access & Security card (filler), Preferences card,
Settings Areas link grid, billing-card branding-status duplicate,
SettingsLinkCard helper. Default export format moves to Profile
Settings where it belongs (personal preference, not account).

856 -> 710 lines on the index. tsc, eslint, vite build clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 23:57:29 -04:00
0f90c0e199 refactor(sidebar): collapse rail/sections to single-IA, log docs
- Sidebar: kill the drifting railGroups + sections dual definition.
  Single source of truth (workItems / libraryItems / footerItems)
  rendered in both pinned and rail modes; pin/unpin is a width and
  label affordance, not an IA switch. Hairline divider replaces
  section labels. Guides moves to the footer alongside Account.
  Renames: Home -> Dashboard, History -> Sessions, Insights -> Analytics.
- CURRENT-STATE.md: log PR #158 (session impeccable pass + tasklane
  keyboard flow) under "Recently shipped".
- PRODUCT.md: design-context source of truth (users, brand, aesthetic);
  sibling to DESIGN-SYSTEM.md.
- skills-lock.json: lock /impeccable + /documentation-writer skill
  versions so other sessions reproduce the same tooling state.
- Drop stale .impeccable.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 22:50:19 -04:00
93fa4eac5c Merge pull request 'feat(guides): rewrite in-product User Guides as Diátaxis how-tos' (#159) from feat/guides-diataxis-rewrite into main
All checks were successful
CI / frontend (push) Successful in 4m57s
Mirror to GitHub / mirror (push) Successful in 6s
CI / backend (push) Successful in 10m38s
CI / e2e (push) Successful in 12m31s
2026-05-02 02:19:53 +00:00
dc71d5873b docs(ai): mark guides rewrite as merged in handoff and current task
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 5m1s
CI / backend (pull_request) Successful in 13m8s
CI / e2e (pull_request) Successful in 18m32s
Update HANDOFF.md, CURRENT_TASK.md, and SESSION_LOG.md to reflect
that PR #159 is being merged into main, replacing the in-flight
"uncommitted" language with the merged-state rollup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:25:44 -04:00
307a6285e6 feat(guides): rewrite in-product User Guides as Diátaxis how-tos
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
CI / frontend (pull_request) Successful in 4m57s
CI / backend (pull_request) Successful in 10m21s
CI / e2e (pull_request) Successful in 12m0s
Replace 15 feature-dump guides with 43 problem-oriented how-tos grouped
under 10 categories. Drop Maintenance Flows / AI Assistant / Flow Assist
Sparkles — those surfaces no longer exist post-FlowPilot pivot. Rename
Step Library → Solutions Library throughout. Correct every "click X in
the sidebar" reference to match live labels (Home, History, Tickets,
Flows, Scripts, Data, Acct).

Schema: add `category: CategoryId` and optional `relatedSlugs` to Guide;
new Category type and `categories` const drive hub ordering. GuidesHubPage
renders category sections (auto-hides empty); GuideDetailPage renders a
related-guides footer when set; GuideCard drops the misleading "N sections"
subtitle.

Fix step.tip markdown rendering — `**bold**` rendered literally because
tip used plain text instead of the same regex replacement used on
instruction.

14 net-new how-tos for FlowPilot-era surfaces with no prior coverage:
tasklane keyboard flow, view-what-we-know, ask-AI mid-session,
pause-and-leave, resolve, record-fix-outcome, escalate (Escalation
Mode), post-docs-to-ticket, send-client-update, build-script-from-scratch,
open-suggested-flow, pin-a-flow, invite-teammate.

Browser-verified against engineer + owner test users (sidebar labels,
account sub-pages, pilot-screen header buttons, Tasks panel, integration
form). tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:16:51 -04:00
5e10005276 Merge pull request 'feat(session): impeccable pass + tasklane keyboard flow' (#158) from feat/session-distill-quieter into main
All checks were successful
CI / frontend (push) Successful in 5m8s
Mirror to GitHub / mirror (push) Successful in 6s
CI / backend (push) Successful in 10m20s
CI / e2e (push) Successful in 10m43s
Reviewed-on: #158
-Michael Chihlas
2026-05-01 21:53:13 +00:00
d3a9031e23 chore(session): bump keyboard hint contrast + drop redundant font-sans
All checks were successful
Mirror to GitHub / mirror (push) Successful in 12s
CI / frontend (pull_request) Successful in 5m33s
CI / backend (pull_request) Successful in 10m57s
CI / e2e (pull_request) Successful in 13m21s
Two small ergonomic fixes after the impeccable pass:

- TaskLane keyboard hints (⏎ submit · ⇧⏎ newline) under each open input
  were rendered at text-muted-foreground/70, just shy of legible at a
  glance. Drop the /70 opacity modifier so they read at full muted weight
  on first look without becoming visually loud.

- 12 sites across the session screen had explicit font-sans utilities,
  but the body default is already IBM Plex Sans (via --font-sans in
  index.css and Tailwind v4's default-sans binding). None of the call
  sites sit inside a font-heading or font-mono cascade, so every
  font-sans there was a no-op. Drop them. ConcludeSessionModal also had
  three "text-xs font-sans text-xs" triplets — drop both the redundant
  font-sans and the doubled text-xs in one pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 16:50:09 -04:00
708e8b977f chore(ai): log followup TODOs surfaced during impeccable pass
Two backlog entries surfaced while polishing the session screen:

- ConcludeSessionModal paused/escalated step forces a single-artifact
  choice (Ticket Notes / Client Update / Email Draft). Real escalations
  often need at least two of the three. Recommended shape: multi-select
  with smart pre-checks per outcome, parallel generation, per-result
  Copy / Post / Send actions. Feature work, deferred.

- bg-card-hover Tailwind class doesn't resolve in CommandPalette. The
  --color-bg-card-hover token generates bg-bg-card-hover (Tailwind v4
  takes the full token name minus --color-). Other call sites use the
  explicit hover:bg-[var(--color-bg-card-hover)] form that works; the
  CommandPalette classes silently produce nothing. Fix is two lines —
  swap to the explicit form, or add a --color-card-hover semantic
  mapping in index.css.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 16:23:15 -04:00
8b0358af3b fix(parameterization): word-boundary check prevents over-eager value match
ParameterizationPreview.tokenize() matched highlight values via raw
seg.text.startsWith(value, cursor) with no word-boundary check and no
minimum length. A param value like "D" (e.g. a drive letter) lit up every
capital D in the script body — Get-ADUser, Add-Type, Disable- all rendered
as proposed-parameter pills.

Add a word-boundary guard: a candidate match is only accepted if either
side of the match either falls at start/end of the segment, OR the
adjacent character is non-alphanumeric. The guard is conditional on
whether the value itself starts/ends with a word char, so values that
begin or end in punctuation (e.g. "D:\\Folder") still match cleanly when
they sit next to whitespace or punctuation.

Surfaced 2026-05-01 while testing the suggested-fix flow with a real
PowerShell script.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 16:23:05 -04:00
0156aae684 feat(session): impeccable session-screen pass + tasklane keyboard flow
Multi-step UX refactor of the assistant chat session screen, run via the
$impeccable skill. Heuristic score moved 24/40 → 33/40 (+9), with the biggest
gains on Aesthetic & Minimalist (1→3), Consistency & Standards (1→3), and
Recognition Rather Than Recall (2→4).

Distill — chat region:
- Remove the "Suggested checks" chip strip + selected-chip detail card; the
  TaskLane is the single canonical home for "what to do next"
- Add an inline Next steps · N pending cue above the latest action-bearing
  AI bubble (anchors attention without duplicating the lane's items)
- Link banner ↔ script-panel lifecycle: collapsing or dismissing the
  ProposalBanner now also hides the InlineNoTemplateDialog / TemplateMatchPanel
- Drop backdrop-blur on the handoff-context overlay (DESIGN-SYSTEM hard rule)

Quieter — drop decoration overshoot:
- Remove 3px side stripes on TaskLane done cards, all 6 ProposalBanner modes,
  WhatWeKnowItem fact rows
- Drop bg-gradient surfaces on WhatWeKnow + every ProposalBanner mode
- Drop 2px accent borderTop on the TaskLane header
- Replace bordered avatar boxes in banners with inline state-colored icons
- Each surface now uses a single decoration channel (top border + inline icon)

Layout:
- Header consolidates to Resolve + Escalate + ⋯ kebab; Context, New Ticket,
  Update Ticket, Pause now live behind the kebab on desktop, with feature
  parity in the existing mobile overflow menu
- Messages column anchors to max-w-3xl mx-auto to match the composer
- Chat bubbles drop from rounded-2xl to rounded-xl for vocabulary alignment

Typeset:
- Unify text sizing from 14 distinct sizes (with sub-pixel oddities and
  rem/px duplicates) to a 5-step scale: 10px / 11px / text-xs / 13px / text-sm

WhatWeKnow collapsible:
- Header is now a toggle; section body hides when collapsed
- Auto-collapses on first render when facts ≥ 5 so Questions / Diagnostic
  Checks stay above the fold
- Engineer's choice persists in sessionStorage per session and beats the
  auto-collapse heuristic on subsequent renders
- key=activeChatId on both render sites resets state cleanly across sessions

Polish:
- Split MessageCircleQuestion into Pencil (question Answer CTA, write
  affordance) + HelpCircle (per-check Explain toggle, universal help icon) —
  same icon for two different jobs was a discoverability bug
- Drop redundant text-xs from font-sans text-[0.625rem] / text-[0.6875rem]
  double-class definitions; the more-specific size always wins

TaskLane keyboard flow:
- Enter submits and auto-advances to the next pending task; Shift+Enter
  inserts a newline (consistent across question and action textareas — paste
  events don't fire keydown, so paste-then-Enter still works as expected)
- Esc cancels (same as the Cancel button)
- After the last pending task is submitted, focus moves to the Send Responses
  button so the engineer can fire the whole batch with one more keystroke
- Subtle hint row under each open input teaches the shortcut

Type-check, lint, and build all clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 16:22:50 -04:00
4d8b107121 wip(handoff): start issue cleanup plan sections 1 and 2
Co-Authored-By: Codex <noreply@openai.com>
2026-05-01 02:04:19 -04:00
a21fe93454 wip(handoff): clean stale TODOs and plan issue cleanup
Co-Authored-By: Codex <noreply@openai.com>
2026-05-01 01:47:41 -04:00
595844de0b wip(handoff): audit TODO and Gitea issue validity
Co-Authored-By: Codex <noreply@openai.com>
2026-05-01 01:41:37 -04:00
b74d3cf584 Merge pull request 'chore(ai): post-#156 handoff + log shipped features in CHANGELOG/CURRENT-STATE' (#157) from chore/post-156-handoff into main
All checks were successful
CI / backend (push) Successful in 10m46s
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (push) Successful in 5m47s
CI / e2e (push) Successful in 10m33s
Reviewed-on: #157
by Michael Chihlas
2026-05-01 04:38:22 +00:00
50ddacdb66 docs: log #155 + #156 in CHANGELOG/CURRENT-STATE
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s
CI / frontend (pull_request) Successful in 5m4s
CI / backend (pull_request) Successful in 10m25s
CI / e2e (pull_request) Successful in 10m41s
Adds Unreleased entries for the Escalation Mode wedge and the
suggested-fix Awaiting verification outcome — both user-visible
features merged this week. Refreshes CURRENT-STATE last-updated
date to 2026-05-01 and adds a "Recently shipped (post-0.1.0.0)"
quick-reference block at the top.

VERSION untouched (still 0.1.0.0; pre-PMF, no release scheduled).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 00:32:01 -04:00
a5e2dcf43f chore(ai): post-#156 handoff — feature shipped, QA report attached
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
Updates the .ai/ handoff trio after PR #156 merge:
- CURRENT_TASK.md: clear active task; record #156 in Recently shipped
  alongside #155 with one-line summary and QA-report pointer.
- HANDOFF.md: rewrite resume point as "pick next from TODO/roadmap";
  document carry-forward env quirks (CONTAINER=1 for Chromium,
  docker-01 hosts entry, multi-head alembic state).
- SESSION_LOG.md: append session entry for QA + merge.

Also includes the .gstack/qa-reports/ artifacts (report + 8 screenshots).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 23:45:10 -04:00
211 changed files with 21594 additions and 2355 deletions

View File

@@ -1,45 +1,25 @@
# CURRENT_TASK.md
**Task:** Add a fourth, non-terminal outcome to the suggested-fix banner — **Awaiting verification** (`applied_pending`). Today the verifying banner forces a synchronous verdict (worked / didn't / partial), but a lot of real fixes are async — engineer ran the script but is waiting on the client to power-cycle, AD replication, an O365 license sync. Without a fourth state the banner sits stale or the engineer guesses wrong.
**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.
**Status:****Engineering complete; PR #156 open.** Backend tests, prompt guardrail, frontend tsc/build clean; Alembic migration applies. Pending browser QA.
## Recently shipped
**Branch:** `feat/fix-pending-verification` (off `main` after the Escalation Mode merge).
**PR:** https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/156
## What ships
| Layer | Change |
|---|---|
| Schema | New `FixStatus="applied_pending"` + new `pending_reason` Text column on `session_suggested_fixes`. |
| Migration | `c0f3a4b7e91d` — adds `pending_reason`, extends status CHECK constraint. |
| API | `PATCH /suggested-fixes/{id}/outcome` accepts `applied_pending`, requires `notes`, stamps `applied_at` only (NOT `verified_at`). Pending in/out transitions allowed (parked, like partial). |
| Generators | `resolution_note_generator` and `escalation_package_generator` system prompts handle the new status without real-looking examples; resolution note frames the fix as provisional; escalation package surfaces pending verification as the leading hypothesis with reference to what's being waited on. |
| Frontend | New `BannerMode='pending'` + `PendingBanner` component (info-tone, mirrors `PartialBanner`) with worked / didn't / update reason / dismiss actions; "Waiting to verify…" overflow option in `VerifyingBanner`; nudge "Still checking" now records pending with a reason instead of just silencing; `AssistantChatPage` banner-mode derivation maps `applied_pending → 'pending'`; page-level Resolve/Escalate now handle pending honestly. |
| Tests | 4 new integration tests in `test_fix_outcome_endpoint.py`: pending-requires-notes, pending stores reason + applied_at-not-verified_at, pending→success transition, pending_reason update on re-PATCH. 21/21 pass. Prompt anti-parrot guardrail passes. Frontend `tsc -b` and Vite build pass via Docker. |
## Out of scope (intentionally)
- Cross-session "Follow-ups" dashboard rollup. The chat-anchored `PendingBanner` is the per-session reminder. Add the dashboard surface only if engineers report losing track across multiple pending sessions.
- Optional follow-up timer ("remind me in 30m"). Nice but not the wedge.
## Resume point — DO THIS NEXT
**Browser QA:** verify the new flow end-to-end in dev:
1. Trigger a suggested fix, click Apply, then open the verifying banner overflow → "Waiting to verify…" → enter a reason → confirm `PendingBanner` renders with the reason.
2. From `PendingBanner`, click "It worked" → confirm transition to terminal success and banner dismissal.
3. From `PendingBanner`, click "Update reason" → confirm reason updates server-side.
4. From `PendingBanner`, click "Dismiss" → confirm banner dismissal and no terminal timestamps.
5. Trigger the nudge state (3+ post-apply messages) → click "Still checking" → enter a reason → confirm pending state takes over.
6. From a pending fix, use page-level Resolve → confirm the fix is patched to `applied_success` before the resolution note.
7. From a pending fix, use page-level Escalate → confirm the outcome intercept appears before the conclude modal.
After QA passes, commit/push the local review fixes and merge PR #156.
## Just-shipped (2026-04-30)
**PR #155 — Escalation Mode wedge** merged into main as `ac42f97`. The wedge for ResolutionFlow's GTM (first paying-customer push). Senior tech sees structured handoff context in seconds via the magic-moment screen. Plan: [`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md).
- **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.
- **TaskLane keyboard-first flow** (real feature): Enter submits + auto-advances to next pending task, Shift+Enter newline, Esc cancels, focus jumps to Send Responses after the last submission. Mouse path also auto-advances. Subtle hint row teaches the shortcut.
- **Banner ↔ script panel linked**: collapsing or dismissing the ProposalBanner now also hides the InlineNoTemplateDialog / TemplateMatchPanel; recording any outcome closes both surfaces.
- **WhatWeKnow collapsible**: per-session preference in `sessionStorage` (`rf-whatweknow-collapsed:{sessionId}`); auto-collapses on first render at ≥5 facts.
- **Side fix**: `ParameterizationPreview.tokenize()` word-boundary guard prevents over-eager highlighting of short values like `"D"` (no longer lights up every capital D in `Get-ADUser`).
- Validation: tsc clean, ESLint clean, Vite build clean. Type-check + lint passed at every commit boundary.
- **2026-05-01 — PR #156** Suggested-fix `applied_pending` non-terminal outcome. Merged into `main` as `3ba4532`. Adds:
- Schema/API: `FixStatus="applied_pending"`, `pending_reason` Text column, migration `c0f3a4b7e91d`. `PATCH /suggested-fixes/{id}/outcome` accepts pending, requires notes, stamps `applied_at` only.
- UI: `PendingBanner` (info-tone, worked / didn't / update reason / dismiss). "Waiting to verify…" overflow option in `VerifyingBanner`. Nudge "Still checking" records pending with a reason. Page-level Resolve auto-patches pending → success before resolution flow; page-level Escalate intercepts pending the same way verifying/partial does.
- Generators: `resolution_note_generator` and `escalation_package_generator` system prompts handle the new status without real-looking examples.
- Tests: 4 new in `test_fix_outcome_endpoint.py` (21/21 suite green); prompt anti-parrot guardrail green; tsc + Vite build clean.
- QA report: `.gstack/qa-reports/qa-report-pending-verification-2026-04-30.md` (5/7 scripted checks PASS with concrete evidence; 2 entry-path checks deferred — same handlers verified via tested transitions).
- **2026-04-30 — PR #155** Escalation Mode wedge merged as `ac42f97`. Senior-tech magic-moment screen. Plan: [`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md).
## Two-metric framing (Escalation Mode — read before quoting numbers)
@@ -48,3 +28,10 @@ The in-product `GET /analytics/flowpilot/escalations` endpoint measures *post-cl
## Kill-switch (Escalation Mode)
Week 8: if 0 of 3 pilots produce a verifiable hours-saved-per-week number above 1.0, revisit the wedge.
## Notes for next session
- Drive checks 1 (VerifyingBanner overflow → "Waiting to verify…") and 5 (nudge "Still checking" with 3+ post-apply messages) in real pilot usage to close the QA gap left by `/qa` (the tested handlers cover the same mutations, but the entry-path UI rendering wasn't exercised end-to-end).
- Consider monitoring how often pending fixes get parked vs resolved — if engineers report losing track across sessions, revisit the cross-session "Follow-ups" dashboard rollup that was scoped out.
- After PR #158 lands in real ticket flow, eyeball the keyboard-hint contrast and the WhatWeKnow auto-collapse-at-5 threshold — both were judgment calls (5 was a guess; the contrast bump from `/70` to full muted-foreground was based on my read, not real screen testing). Adjust if the 5-fact threshold feels too aggressive or too lenient mid-session.
- Two follow-ups logged in `.ai/TODO.md` from the impeccable pass: `ConcludeSessionModal` paused/escalated step should allow multi-select (Ticket Notes + Client Update + Email Draft simultaneously) — real feature work; `bg-card-hover` Tailwind class doesn't resolve in `CommandPalette` — two-line fix.

View File

@@ -13,6 +13,16 @@
---
## 2026-05-07 — Standardize backend Python on 3.12
**Context:** Runtime facts had drifted from docs. The backend Dockerfiles and running dev container were already on Python 3.12, GitHub CI had just been updated to 3.12, but project docs still said Python 3.11 and Gitea CI relied on the runner's ambient Python.
**Decision:** Treat Python 3.12 as the backend standard. Pin local pyenv via `.python-version` to 3.12.13, matching the current `python:3.12-slim` container patch level. Add explicit Python 3.12 setup to Gitea CI and keep GitHub CI on Python 3.12.
**Rejected:** Moving Docker/runtime back to Python 3.11. The application was already building and running on 3.12, so reverting the runtime would add churn without a product or dependency reason.
**Consequences:** Native backend work should use `backend/venv` created from Python 3.12.13. Future docs/CI/runtime changes should preserve Python 3.12 unless a deliberate upgrade decision is recorded.
## 2026-04-30 — Add `applied_pending` non-terminal status to suggested fixes
**Context:** The verifying banner forces a synchronous verdict — worked / didn't / partial — but a lot of real MSP fixes are async. Engineer ran the script but is waiting on the client to power-cycle, AD replication, an O365 license sync. With only the existing outcomes, the engineer either leaves the banner stale (eroding the verifying signal) or guesses wrong (corrupting outcome data). User flagged the gap directly. Today's `NudgeBanner` "Still checking" button just silences the nudge — it doesn't tell the system anything.

View File

@@ -2,51 +2,56 @@
# HANDOFF.md
**Last updated:** 2026-05-01 (session 5 — PR #156 review fixes applied)
**Last updated:** 2026-05-07 (PR #162 CI investigation/fixes)
**Active task:** Suggested-fix `applied_pending` outcome. Branch: `feat/fix-pending-verification`. PR #156 open; review fixes applied locally and ready for browser QA, then commit/merge.
**Just-merged:** PR #155 (Escalation Mode wedge) merged into main as `ac42f97`.
**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 #156 was reviewed for missed bugs and three fixes were applied:
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. Page-level **Resolve** now treats `applied_pending` as a verifying state and patches the fix to `applied_success` before opening the resolution flow, avoiding provisional notes on a resolved session.
2. Page-level **Escalate** intercept now catches `applied_pending` as well as verifying/partial, so a pending fix cannot bypass outcome capture before handoff. Intercept copy was generalized from "Verifying state" to "still needs an outcome."
3. `PendingBanner` now includes a **Dismiss** action, matching the PR body and backend's allowed pending → dismissed transition.
4. Real-looking pending examples were removed from `resolution_note_generator` and `escalation_package_generator` system prompts to stay aligned with the prompt anti-parrot guardrail.
Fixed environment drift first:
**Validation on PR #156:**
- 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.
- `docker exec resolutionflow_backend pytest --override-ini="addopts=" tests/test_prompt_anti_parrot.py -q` ✅ 2/2 pass.
- `docker exec resolutionflow_backend pytest --override-ini="addopts=" tests/test_fix_outcome_endpoint.py -q` ✅ 21/21 pass.
- `docker exec -w /app resolutionflow_frontend npx tsc -b` ✅ exit 0.
- `docker exec -w /app resolutionflow_frontend npm run build` ✅ exit 0; only existing Vite large-chunk warning.
- `git diff --check` ✅ clean.
- Previous session also verified `alembic upgrade heads` applied migration `c0f3a4b7e91d` cleanly.
Fixed Gitea runner assumptions next:
## Resume point — DO THIS NEXT
- Added `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs.
- Pushed `fix(ci): set up node in gitea workflow`.
**Browser QA on PR #156** (see CURRENT_TASK.md "Resume point" for the checklist). Include the new review-fix paths: PendingBanner Dismiss, page-level Resolve from pending, and Escalate from pending. Then commit local fixes, push, and merge when QA is green.
Local frontend validation then exposed real lint failures in Phase 2 React code under the current lint stack. The current WIP fixes:
## Key files for PR #156
- `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.
- `backend/app/models/session_suggested_fix.py` — CHECK constraint extended; `pending_reason` Text column.
- `backend/app/schemas/session_suggested_fix.py``applied_pending` added to both `FixStatus` and `FixOutcome` literals; `pending_reason` on response model; updated docstring on `SessionSuggestedFixOutcomeRequest`.
- `backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py` — new migration (rev `c0f3a4b7e91d`, down `71efd2102f49`).
- `backend/app/api/endpoints/session_suggested_fixes.py``patch_outcome` accepts pending, requires notes, stamps applied_at only.
- `backend/app/services/{resolution_note,escalation_package}_generator.py` — system-prompt handling for the new status; `pending_reason` line in input bundle; real-looking examples removed.
- `backend/tests/test_fix_outcome_endpoint.py` — 4 new tests.
- `frontend/src/api/sessionSuggestedFixes.ts` — types updated; `pending_reason` on `SessionSuggestedFix`.
- `frontend/src/components/pilot/ProposalBanner.tsx``'pending'` `BannerMode`; `PendingBanner` component + Dismiss; "Waiting to verify…" overflow option; nudge "Still checking" wired to record pending.
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx` — copy generalized for pending/partial/verifying outcome capture.
- `frontend/src/pages/AssistantChatPage.tsx` — banner-mode derivation maps `applied_pending → 'pending'`; Resolve/Escalate paths now handle pending.
Validation after those frontend changes:
## Watch-outs
- `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.
- Dev stack: backend `:8000`, frontend `:5173`, postgres `:5433` (docker-compose). HMR works. On this host use `docker exec` for Python/npm commands; see `.ai/PROJECT_CONTEXT.md`.
- Test users (Acme MSP, password `TestPass123!`): `engineer@resolutionflow.example.com`, `teamadmin@resolutionflow.example.com`.
- Multi-head alembic state on main is pre-existing (heads `070`, `c0f3a4b7e91d`, `024`); not introduced by this work but worth knowing if `alembic upgrade head` complains — use `upgrade heads` (plural).
- `pending_reason` is preserved as audit trail when an engineer advances pending → success/failed/dismissed; it is not auto-cleared. Intentional.
- Working tree also has documentation edits in `.ai/PROJECT_CONTEXT.md` and `AGENTS.md` describing Docker exec commands. Those were not part of the feature fix but should be preserved.
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. 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
- 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

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

View File

@@ -12,6 +12,104 @@
---
## 2026-05-07 11:45 EDT — Codex — Push PR #162 CI runner setup fixes
- Inspected Gitea PR #162 via public API. PR head was `380fcf7` and all CI jobs failed quickly; pushed local commits through `4a37a47`, including Python 3.12 setup for Gitea backend/e2e jobs.
- New run on `4a37a47` showed frontend still failed quickly while backend/e2e remained pending. Root cause likely same class of runner drift: Gitea frontend/e2e jobs used `npm` without setting up Node.
- Added explicit `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs. This keeps CI from relying on runner ambient Node/npm.
- Files touched: `.gitea/workflows/ci.yml`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-07 11:30 EDT — Codex — Standardize backend Python on 3.12
- Standardized repo declarations around Python 3.12: added `.python-version` pinned to 3.12.13, updated stale Python 3.11 docs, and added explicit Python 3.12 setup steps to Gitea CI. GitHub CI was already updated to Python 3.12 by the user.
- Installed pyenv Python 3.12.13 and created `backend/venv` from that interpreter. Installed `backend/requirements-dev.txt` into the venv.
- Verified native `python --version` and venv `python --version` both report 3.12.13. Verified native `pytest 8.4.2` and `alembic 1.18.3` with explicit safe test env vars; plain pytest import still depends on local `.env` values being valid.
- Rebuilt and restarted the dev backend container with `docker compose -f docker-compose.dev.yml build backend` and `up -d backend`; confirmed `docker exec resolutionflow_backend python --version` reports 3.12.13.
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `README.md`, `DEV-ENV.md`, `.ai/PROJECT_CONTEXT.md`, `.ai/DECISIONS.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-07 11:14 EDT — Codex — Recheck native Python availability
- Re-ran the startup ritual and checked the host Python state after the user reported fixing the missing native Python issue.
- Verified `python` and `python3` resolve to `/config/.pyenv/shims/*` and run Python 3.12.10. `pip` and `pip3` are available as pip 25.0.1 under the same pyenv install.
- Confirmed there is no native `python3.11`, pyenv currently lists only `3.12.10`, no repo virtualenv exists under `backend/venv`, `backend/.venv`, or root `.venv`, and `python -m pytest --version` from `backend/` fails with `No module named pytest`.
- Conclusion: native Python is present, but it is not yet a ready backend dev/test environment for ResolutionFlow. Docker remains the reliable path for pytest/alembic until a Python 3.11 virtualenv with `backend/requirements*.txt` is installed.
- Files touched: `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-06 — Claude — Self-serve signup Phase 2 (frontend + cutover code) shipped on `feat/self-serve-signup-phase-2`
- Executed Tasks 2744 of `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md` via `superpowers:subagent-driven-development`. 18 commits on `feat/self-serve-signup-phase-2` (off `main` `f918b76`); HEAD `c75ce0c`. Each task: dispatched implementer subagent with full task text + curated context, then spec-compliance + code-quality review subagents; review issues either fixed in-flight via `git commit --amend` or noted as deferred scope.
- Backend (Phase I, Tasks 2731): `BillingService.open_customer_portal` + `GET /billing/portal-session`; `PATCH /users/me/onboarding-step` + dismiss-rest sibling; public `POST /sales-leads` (5/hr/IP); `/admin/plan-limits` GET/PUT round-trips `plan_billing` in one transaction with NOT-NULL guards on `display_name|is_public|is_archived|sort_order`; `BillingService.invalidate_billing_cache` no-op stub; `GET /config/public` (`{self_serve_enabled, oauth_providers}`); `auth/register` invite-code gate now `REQUIRE_INVITE_CODE and not SELF_SERVE_ENABLED and not invite_code`. Also (T36): `GET /accounts/invites/{code}/lookup` (public, joinedload account+inviter); OAuth callback honors `account_invite_code+invited_email`, rejects existing-email user with `email_already_registered_use_login`. Also (T42, T44): `GET /plans/public`; `POST /beta-signup` returns 307 to `${FRONTEND_URL}/register?from=beta`. `OnboardingStatus` extended with `email_verified`+`shop_setup_done`; `UserResponse` exposes `onboarding_step_completed`+`onboarding_dismissed`.
- Frontend (Phases JN, Tasks 3244): `useBillingStore` Zustand store + `useBillingPoll` mounted in `AppLayout`; `useFeature` / `useFeatureLimit` (60s module cache, lazy `/usage/{field}` fetch with silent fallback — endpoint deferred) / `useTrialBanner` (fractional-day boundary so 24h = warning); `FeatureGate` / `UpgradePrompt` (inline `FEATURE_CATALOG`) / `EmailVerificationGate` (mounted in AppLayout around `<ViewTransitionOutlet />`). `RegisterPage` redesign with OAuth buttons + invite-code conditional; `OAuthCallbackPage` with CSRF state validation + UTF-8-safe base64url state encoding (factored into `lib/oauthState.ts`); `useAppConfig` hook. `AcceptInvitePage` at `/accept-invite` with locked email; `EmailVerificationBanner` refactored to design-system tokens; `EmailVerificationWall` polished; `VerifyEmailPage` at `/verify-email` with single-fire ref guard; `WelcomeRouter` + `WelcomeStep1/2/3` at `/welcome*`; `TrialPill` in topbar (8 stages); `NextStepCard` + `SetupChecklist` (replace orphaned `OnboardingChecklist`); `PricingPage` at `/pricing`; `ContactSalesPage` at `/contact-sales`; `LandingPage` got "See pricing" CTA + replaced beta-signup form with `<Link>`.
- Final cross-cutting review caught one real bug — relative `/beta-signup` 307 target landing on API origin instead of frontend — fixed via amend (HEAD `c75ce0c`).
- Tests: ~165+ new tests across backend pytest + frontend vitest. Sweep at end-of-branch all-green; tsc -b clean.
- Phase O (Tasks 4547) is explicit manual operations: Stripe live-mode setup, internal validation via `INTERNAL_TESTER_EMAILS` per-email allowlist (backend support for that allowlist is NOT yet built), feature-flag flip + week-1 monitoring. Surfaced as the resume point in HANDOFF.md.
- Working tree was dirty before this session (`.ai/HANDOFF.md`, `.env.example`s, `core.*` core dumps, `docs/architecture/`, `docs/tutorials/`); intentionally not staged into Phase 2 commits. Files touched: see `git log --oneline f918b76..HEAD` on `feat/self-serve-signup-phase-2`.
---
## 2026-05-02 ~01:00 UTC — Claude — In-product User Guides Diátaxis rewrite shipped (PR #159)
- Audited the in-product `/guides` collection against live UI via `/browse` (engineer + owner test users). Existing 15 guides predated the FlowPilot pivot — every "click X in the sidebar" reference was wrong (Dashboard → Home, All Flows → Flows, Sessions → History, Exports gone, etc.). Three guides described surfaces that no longer exist: Maintenance Flows, AI Assistant page, Flow Assist Sparkles button. Findings written to `/tmp/guides-audit.md`.
- Rebuilt `frontend/src/data/guides.ts` from scratch as 43 problem-oriented Diátaxis how-tos under 10 categories. Single-outcome each, terse imperative steps, real UI labels (Create New, Sign in, Manage, Build New Script, Send Invite, Save Settings, Create Category, etc.). Added `category: CategoryId` and optional `relatedSlugs?: string[]` to the `Guide` interface; new `Category` type and `categories` const drive the hub layout. `GuidesHubPage` now renders category sections (auto-hides empty); `GuideDetailPage` renders a Related guides footer; `GuideCard` lost its misleading "N sections" subtitle.
- Fixed `GuideSection.tsx`: `step.tip` was rendered as plain text so `**bold**` markdown in tips rendered literally. Applied the same regex replacement used on `step.instruction`. Verified against `/guides/start-a-session` tip block.
- Authored 14 net-new how-tos for FlowPilot-era surfaces with no prior coverage: tasklane-keyboard-flow, view-what-we-know, ask-ai-mid-session, pause-and-leave-session, resolve-a-session, record-suggested-fix-outcome, escalate-a-session, post-docs-to-ticket, send-client-update, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate. Dropped change-teammate-role from scope — couldn't verify the role-change UI control without a non-owner test member.
- Verified owner-only surfaces with `pro@resolutionflow.example.com`: Membership inline form on `/account` (not a separate `/team-members` route), `/account/categories` real button is **Create Category** (not Add), `/account/chat-retention` real fields are **Retention Period (days)** + **Max Conversations** + **Save Settings**, `/account/integrations` form fields confirmed. Three guides corrected post-audit.
- Smoke-tested all 43 detail pages — every slug renders, no "Guide Not Found" fallthroughs.
- Added `100.64.78.44 docker-01` entry to `/etc/hosts` (user ran `sudo tee` from a normal terminal because the LXC `!` shell prefix can't drive interactive sudo). Should now persist across `/browse` sessions on this LXC.
- `docker exec -w /app resolutionflow_frontend npx tsc -b` clean.
- Files touched: `frontend/src/data/guides.ts`, `frontend/src/pages/GuidesHubPage.tsx`, `frontend/src/pages/GuideDetailPage.tsx`, `frontend/src/components/guides/GuideCard.tsx`, `frontend/src/components/guides/GuideSection.tsx`, `CHANGELOG.md`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`. Working tree dirty — user not yet asked to commit.
---
## 2026-05-01 21:55 UTC — Claude — Session-screen impeccable pass + tasklane keyboard flow shipped (PR #158)
- Ran the `/impeccable` skill against the assistant chat session screen (chat history / chat bar / TaskLane). Initial design-health score: 24/40 with explicit DESIGN-SYSTEM violations (gradient surfaces in WhatWeKnow + ProposalBanner, side stripes in TaskLane done states + every banner mode, accent borderTop on lane header, backdrop blur on handoff overlay).
- Walked through all 5 impeccable sub-passes (distill, quieter, layout, typeset, polish). Score after pass: 33/40 (+9). Biggest gains in Aesthetic & Minimalist (1→3), Consistency & Standards (1→3), Recognition Rather Than Recall (2→4).
- Inline iterations on top of the impeccable steps: linked banner ↔ script-panel lifecycle (collapse hides both, dismiss closes both, any outcome closes both); collapsible WhatWeKnow with `sessionStorage` memory + auto-collapse-at-5-facts; full keyboard flow on TaskLane (Enter submits + auto-advances, Shift+Enter newline, Esc cancels, focus jumps to Send Responses after the last task).
- Side fix: `ParameterizationPreview` was over-highlighting short parameter values (a `"D"` lit up every capital D in `Get-ADUser`/`Add-Type`/etc.). Added a word-boundary guard, conditional on whether the value itself starts/ends with a word character so values with leading punctuation (`"D:\\Folder"`) still match cleanly.
- Followups logged in `.ai/TODO.md`: `ConcludeSessionModal` multi-select for paused/escalated outcomes (real feature work — engineers often need ≥2 of Ticket Notes / Client Update / Email Draft), and `bg-card-hover` Tailwind drift in `CommandPalette` (silently broken classes — two-line fix).
- Branched as `feat/session-distill-quieter`, 4 commits (impeccable pass, parameterize fix, TODO followups, hint contrast + font-sans audit). PR #158 created via Gitea API (`$GITEA_TOKEN` env, no `gh` on this LXC). Merged into `main` as `5e10005`. Local branch deleted.
- Validation at every commit boundary: `docker exec -w /app resolutionflow_frontend npx tsc -b`, `npm run lint`, and `npm run build` all clean.
- Files touched: 14 frontend files (TaskLane, AssistantChatPage, ChatMessage, ProposalBanner, WhatWeKnow, WhatWeKnowItem, SuggestedFlowCard, ChatSidebar, ConcludeSessionModal, ChatTabStrip, ActionCardGroup, AddNoteButton, ParameterizationPreview), `.ai/TODO.md`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `CHANGELOG.md`, `CURRENT-STATE.md`.
## 2026-05-01 07:20 UTC — Codex — Start issue cleanup plan sections 1 and 2
- Started `docs/plans/2026-05-01-issue-cleanup-plan.md` sections 1 and 2.
- Cleaned frontend lint to zero warnings by removing stale lint disables, tightening hook dependencies, and adding justified comments where effects are intentionally keyed to route or owner identity.
- Added e2e selectors for session history controls and the FlowPilot command-palette entry.
- Added `AssistantChatPage` observability for unexpected `currentChatRef` stale async discards.
- Added `TaskLane` diagnostic help affordances for common command categories and documented #128 as "keep the existing responsive side-panel/bottom-drawer behavior until pilot feedback says otherwise."
- Verified `npm run lint`, `npx tsc -b`, and `npm run build` in `resolutionflow_frontend`; build only reported the existing Vite large-chunk warning.
- Files touched: frontend lint-cleanup files, `frontend/src/components/assistant/TaskLane.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/pages/SessionHistoryPage.tsx`, `frontend/src/components/layout/CommandPalette.tsx`, `docs/plans/2026-05-01-issue-cleanup-plan.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-01 06:05 UTC — Codex — Clean stale TODOs and add issue cleanup plan
- Removed the resolved pytest-xdist item from `.ai/TODO.md` and reset "Up next" to no selected task.
- Removed the resolved "Add role gate to handoff claim endpoint" backlog item from `.ai/TODO.md`.
- Updated the frontend lint cleanup TODO from 23 warnings to the current `npm run lint` result: 24 warnings, 0 errors.
- Tried to close Gitea #127 through the API, but this environment has no Gitea token; API returned `401 token is required`.
- Added `docs/plans/2026-05-01-issue-cleanup-plan.md` with safe tracker actions and a recommended order for clearing remaining issues.
- Files touched: `.ai/TODO.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `docs/plans/2026-05-01-issue-cleanup-plan.md`.
## 2026-05-01 05:40 UTC — Codex — Audit TODO backlog and Gitea issue validity
- Compared `.ai/TODO.md`, inline code TODOs, and open Gitea issues against current `main`.
- Verified pytest-xdist is already shipped (`backend/requirements-dev.txt`, `backend/tests/conftest.py`, `.gitea/workflows/ci.yml`) so the `.ai/TODO.md` xdist item is stale. Ran frontend lint in Docker; current state is `0 errors, 24 warnings`, so the lint cleanup item remains valid but its count is stale.
- Verified Gitea issue status: #58, #60, #128, #129, #130 remain valid; #66 is partially resolved by current `.rfflow` import/export and should be narrowed to template packs/marketplace; #127 is mostly resolved by current UI copy and prompt boundaries unless an always-visible scope badge is still wanted. Open PR #124 is stale/unmergeable against current `main`.
- Verified inline TODOs still valid: post-session contextual feedback prompt, FlowPilot analytics domain/time-entry placeholders, prompt-cache verification note unless live telemetry has confirmed it, proposal `modify` flow editor wiring, and procedural ghost-step accept/dismiss buttons.
- Files touched: `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-01 03:45 UTC — Claude Opus 4.7 — QA, merge, and ship PR #156 pending-verification
- Committed two logical units of pending work on `feat/fix-pending-verification`: prior session's local review fixes as `5bee264` (Codex-attributed, 5 source files + 3 `.ai/` notes) and this session's docker-exec docs as `15042af` (Claude-attributed, `.ai/PROJECT_CONTEXT.md` + `AGENTS.md`). Cleaned up a 20MB `core.22120` Chromium dump left behind by an earlier sandbox crash.
- Resolved a tooling gap surfaced by Codex's prior session ("npm/python/python3 are not on the host path") by documenting that this code-server LXC uses bun + docker for the toolchain. The `docker exec resolutionflow_{backend,frontend}` form is now the canonical command pattern in `.ai/PROJECT_CONTEXT.md`.
- Got `$B`/Playwright Chromium running in the code-server LXC. After the user's restart cleared the AppArmor unprivileged-userns block, Chromium still aborted at the deeper `sandbox/linux/services/credentials.cc` layer because of the LXC namespace constraint. Workaround: launch browse with `CONTAINER=1` so it auto-adds `--no-sandbox`. Also added `100.64.78.44 docker-01` to code-server's `/etc/hosts` (via `docker exec -u 0`) so the headless browser could resolve the bake-in `VITE_API_URL`.
- Drove `/qa` against the dev stack at `http://100.64.78.44:5173`. No naturally-occurring `applied_pending` fix existed in the DB, so seeded session `4a558056-bcbd-4b51-925b-248d70eb318d` and fix `cd4ff2fd-751a-4bcb-8cfa-3c77b4864fb2` into the test state (un-resolved session, swapped supersession on the two fixes). Saved a restore script first; verified DB matches pre-test state after teardown.
- QA result: 5/7 scripted checks PASS with concrete DB + UI evidence. Banner renders correctly ("Awaiting verification" header, "Parked" tag, fix title + pending_reason, 4 actions). "Update reason" updates server-side. "It worked" → `applied_success` with `verified_at` stamped. "Dismiss" → `dismissed` with no terminal timestamp. Page-level Resolve auto-patches `applied_pending``applied_success` before the resolution flow opens. Page-level Escalate fires `EscalateInterceptDialog` with the generalized "still needs an outcome" copy. 2 entry-path checks (VerifyingBanner overflow, nudge "Still checking") deferred because they require live AI-generated chat state to drive; the mutating handlers behind those entry paths are verified via the tested transitions. Report at `.gstack/qa-reports/qa-report-pending-verification-2026-04-30.md`.
- Pushed `feat/fix-pending-verification`. Polled Gitea actions runs 161; required `CI / frontend` and `CI / backend` plus `CI / e2e` all green. Merged via Gitea API as a merge commit (`3ba4532`).
- Post-merge cleanup: fast-forwarded local `main`, deleted `feat/fix-pending-verification` locally and on the remote. Wrote handoff updates on `chore/post-156-handoff` matching the prior `chore/post-153-handoff` pattern.
- Files touched (this session): `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/PROJECT_CONTEXT.md`, `.ai/SESSION_LOG.md`, `AGENTS.md`, `.gstack/qa-reports/qa-report-pending-verification-2026-04-30.md`, `.gstack/qa-reports/screenshots/01-08*.png`. Plus the two prior-session-authored commits committed by this session (5 source + 3 `.ai/` notes).
## 2026-05-01 02:24 UTC — Codex — Review-fix PR #156 pending-verification flow
- Reviewed PR #156 for bugs and found three actionable gaps: pending fixes could be resolved from the page-level Resolve path without updating the fix outcome, the PendingBanner lacked the dismiss action described in the PR body, and new system-prompt examples used real-looking pending reasons contrary to the prompt anti-parrot lesson.
@@ -238,3 +336,13 @@
- Files touched: `.ai/*.md` (created), `CLAUDE.md` (rewritten), `AGENTS.md` (created), `SESSION-HANDOFF.md` (deleted).
- Follow-up (same day): Codex review pass flagged stale SaaS-role claim and incomplete file-listings carried over from the pre-migration CLAUDE.md. Verified against `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`, `backend/app/api/deps.py`, `backend/app/api/router.py`, and `backend/app/services/psa/`. Corrected PROJECT_CONTEXT.md role hierarchy (`super_admin > owner > engineer > viewer`, not `team_admin`), added `require_account_owner` / `require_team_admin` to deps list, replaced stale endpoint comment with a summary pointing at `api/router.py`, added `exceptions.py` + `ticket_context.py` to the PSA file list. Also replaced seed-example content in `CURRENT_TASK.md` and `TODO.md` with clearer empty-state sentinels.
- Branch cleanup (same day): committed pending test-isolation work as `b14a16a chore(tests): gate RLS tests behind RUN_RLS_TESTS flag`, new Phase 9 review doc as `b3506b5 docs(pilot): phase 9 review issues`, and `.remember/` gitignore entry as `b3be1e0 chore: ignore .remember/ skill runtime state`. Deleted `docs/landing-handoff/` (prepared for external design work, not meant to live in the repo). Working tree clean; 3 cleanup commits unpushed.
## 2026-05-07 UTC — Codex — Resolve PR #162 CI failures
- Investigated Gitea PR #162 failing checks for `feat/self-serve-signup-phase-2`. Public status metadata was available, but job logs required Gitea login and no token was present.
- Standardized backend development/CI Python on 3.12.13 to match the Docker image: added `.python-version`, updated Gitea CI Python setup, rebuilt the local backend virtualenv, and verified native `pytest` / `alembic` command availability with explicit local env.
- Added explicit Node 20 setup to Gitea frontend and e2e jobs so CI no longer depends on the runner's ambient Node installation.
- Reproduced the remaining frontend failure locally. Lint failed on Phase 2 React code because the current eslint stack flags exported pure helpers, render-time `Date.now()`, and effect-driven state synchronization.
- Patched the affected frontend surfaces narrowly: dashboard helper exports, app-config cache handling, feature-limit cache/fetch state, trial-banner time capture, invite/OAuth route error state, pricing loading state, and OAuth authorize URL helper export.
- Verified sequential frontend CI locally in Docker: `npm run lint` passed, `npm run test:coverage` passed (`198` tests), and `npm run build` passed with only Vite chunk-size warnings.
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `.ai/*`, `README.md`, `DEV-ENV.md`, and the frontend lint-fix files under `frontend/src/components/dashboard`, `frontend/src/hooks`, and `frontend/src/pages`.

View File

@@ -5,11 +5,11 @@
## Up next
- [ ] **Parallelize backend pytest with pytest-xdist.** ✅ landing as PR #151. Verified locally: backend suite 22 min → 4m 28s with `-n auto` on the 8-core homelab runner. Per-worker DB isolation via `PYTEST_XDIST_WORKER` in conftest.py.
None selected. Pick from the backlog below or `03-DEVELOPMENT-ROADMAP.md`.
## Backlog
- [ ] **Frontend lint warnings cleanup.** 23 `react-hooks/exhaustive-deps` warnings remain after PR #149 (mostly missing-deps in useEffect). Either fix them or audit them for known-safe ones and add eslint-disable comments. Not blocking CI today.
- [ ] **Frontend lint warnings cleanup.** `npm run lint` currently reports 24 warnings (0 errors): mostly `react-hooks/exhaustive-deps` plus a few unused eslint-disable directives. Either fix them or audit known-safe ones and add/remove eslint-disable comments intentionally. Not blocking CI today.
- [ ] **Audit `filterwarnings` ignores added in `wip(handoff): restore backend suite to green`.** Codex added narrow `ResourceWarning` filters for unclosed socket/transport/event-loop noise from pytest-asyncio teardown. Worth periodically reviewing whether those are still needed (e.g. when bumping pytest-asyncio) — if a real warning appears in those forms it would be silenced.
- [ ] **Add `data-testid` attributes to e2e-critical interactive elements.** PR #152 fixed five Playwright tests by chasing UI-text changes (`Sessions``Session History`, `Account Settings``Account Management`, `/assistant``/pilot`, "Flow Sessions" tab, Resume button on session cards). Each was a one-line selector update, but every UI churn re-breaks them. Adding stable `data-testid` attributes on the targeted elements (page heading wrappers, tab nav, primary action buttons) and switching tests to `getByTestId` would make these immune to copy/route renames. Scope it small — start with `SessionHistoryPage` heading, the AI/Flow Sessions tab buttons, the per-session `Resume` button, and the command-palette FlowPilot option.
- [ ] **Per-test transactional rollback in `test_db` fixture.** Bigger engineering than xdist (which we already shipped). Instead of `DROP SCHEMA public CASCADE` per test, wrap each test in a savepoint and rollback at teardown. ~30-40% additional speedup on top of xdist for test-DB-heavy tests. Real refactor; only worth it if the suite gets significantly larger or runs more frequently.
@@ -20,4 +20,6 @@
- [ ] **Mobile/responsive design for EscalationQueue + handoff-context screen.** Pre-PMF wedge demo targets desktop only — MSP techs work on laptops/desktops in shop environments. Once 3+ paying customers exist and a tech requests mobile (likely on-call use case), spec the responsive behavior: stacked card layout below `sm:` breakpoint, full-bleed handoff-context overlay on mobile, swipe-to-claim gesture instead of Pick Up button. Surfaced from /plan-design-review on the Escalation-Mode wedge plan.
- [ ] **(MOVED IN-SCOPE for Escalation Mode v1, 2026-04-27)** ~~Add role gate to handoff claim endpoint.~~ Codex review correctly flagged this as wedge-relevant (the race-condition story depends on auth gating). Now part of the Escalation Mode v1 build, not a deferred TODO.
- [ ] **`bg-card-hover` Tailwind class doesn't resolve.** [`frontend/src/components/layout/CommandPalette.tsx:450-451`](../frontend/src/components/layout/CommandPalette.tsx) uses `bg-card-hover` as a Tailwind utility, but Tailwind v4 generates `bg-{token}` from `--color-{token}` — and the token in [`frontend/src/index.css:15`](../frontend/src/index.css) is `--color-bg-card-hover`, which generates `bg-bg-card-hover`, not `bg-card-hover`. So those classes silently produce nothing. Other call sites (KnowledgeBaseCards, TeamSummary, ProposalBanner) use the explicit `hover:bg-[var(--color-bg-card-hover)]` form which works. Fix: change the CommandPalette classes to the explicit-var form, OR add a `--color-card-hover` semantic mapping in index.css alongside `--color-card`. Surfaced 2026-05-01 during impeccable polish sweep.
- [ ] **`ConcludeSessionModal` paused/escalated step forces single-artifact choice — should allow multi-select.** [`frontend/src/components/assistant/ConcludeSessionModal.tsx`](../frontend/src/components/assistant/ConcludeSessionModal.tsx) ~lines 430-474 ("Paused/Escalated: status update options"). Today the engineer clicks ONE of Ticket Notes / Client Update / Email Draft, the buttons disappear, and the result replaces them. Real MSP escalations almost always need at least two: technical notes for the next engineer's PSA AND a non-technical client update. Same for pause (client update + ticket notes for context when resuming). Recommended shape: multi-select with smart defaults — three checkboxes (`☑ Ticket Notes ☑ Client Update ☐ Email Draft`); for `escalated` pre-check Ticket Notes + Client Update; for `paused` pre-check Client Update only. One "Generate" button fires all selected in parallel via existing `aiSessionsApi.generateStatusUpdate(...)` (already supports the three `audience` values: `ticket_notes`, `client_update`, `email_draft`). Each result renders in its own card with its own Copy / Post-to-PSA / Send-Email action. Surfaced 2026-05-01. Feature work, not polish — touches streaming wiring for parallel calls.

View File

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

View File

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

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12.13

View File

@@ -28,7 +28,14 @@ All notable changes to ResolutionFlow are documented here.
## [Unreleased]
### Changed
- **In-product User Guides rewrite** — replaced 15 feature-dump guides with 43 problem-oriented Diátaxis how-tos grouped under 10 categories (Getting started, Working a pilot session, Closing out a session, Documentation & sharing, Authoring flows, Reusable assets, AI assistance, PSA integrations, Account & team admin, Analytics). Dropped three deprecated guides (Maintenance Flows, AI Assistant page, Flow Assist sparkle button — UI no longer exists). Renamed Step Library → Solutions Library to match canonical product terminology. Corrected sidebar entry-path references throughout (Dashboard → Home, All Flows → Flows, Sessions → History, Analytics → Data, etc.). Added `category` and optional `relatedSlugs` to the Guide schema; `GuidesHubPage` now renders category sections; `GuideDetailPage` shows a "Related guides" footer when set. Authored 14 net-new how-tos covering FlowPilot-era surfaces with no prior coverage: tasklane keyboard flow, what-we-know panel, ask-the-AI mid-session, pause-and-leave, resolve a session, record a suggested-fix outcome, escalate (Escalation Mode), post docs to a ConnectWise ticket, share a client update mid-session, build a script with Script Builder, open an AI-suggested flow, pin a flow, and invite a teammate. Fixed a long-standing rendering bug where `**bold**` markdown in `step.tip` rendered literally instead of bolded — the same regex replacement now runs on tips as on instructions. Killed the misleading "N sections" subtitle on guide cards (single-section how-tos make the count noise).
### Added
- **TaskLane keyboard-first answer flow** (#158) — Enter submits and auto-advances to the next pending task; Shift+Enter inserts a newline; Esc cancels; after the last task, focus jumps to the Send Responses button so the engineer can fire the whole batch with one more keystroke. Mouse path also auto-advances. Subtle hint row (`⏎ submit · ⇧⏎ newline`) under each open input teaches the shortcut.
- **Collapsible "What we know" section** (#158) — TaskLane's facts list is now a collapsible section with per-session memory in `sessionStorage`. Auto-collapses on first render at ≥5 facts so Questions and Diagnostic Checks stay above the fold; engineer's explicit toggle always wins.
- **Escalation Mode wedge** (#155) — when an engineer escalates, the senior tech who claims the session lands on a magic-moment handoff-context screen with the structured briefing visible in seconds (no scrolling, no chat re-read). Live SSE pushes new arrivals to anyone watching the queue, atomic claim resolves race conditions, the queue auto-excludes the claimed session, the claiming user retains chat ownership for AI briefings, and a new analytics endpoint tracks post-claim time-to-first-action so you can see real minutes recovered (paired with a manual baseline — see CURRENT_TASK.md two-metric framing).
- **Suggested-fix "Awaiting verification" outcome** (#156) — when a fix needs external confirmation (client power-cycle, AD replication, license sync) you can park it in `applied_pending` instead of forcing a worked / didn't / partial verdict. The new PendingBanner shows the parked status with worked / didn't / update reason / dismiss actions. The "Still checking" nudge records pending with a reason instead of just silencing. Page-level Resolve auto-patches pending → success before the resolution flow opens; page-level Escalate intercepts pending the same way it intercepts verifying/partial. Resolution notes and escalation packages frame the pending state honestly (provisional fix; leading hypothesis with what's being waited on).
- Tree Templates + Import/Export marketplace (#66)
- Recurring Issue Detection — client-specific pattern alerts (#60)
- Step Feedback Flag — "This Step is Wrong" reporting (#58)
@@ -42,6 +49,8 @@ All notable changes to ResolutionFlow are documented here.
- **Image support in Assistant Chat** — paste/attach images in chat input, uploaded to S3, resized for vision model, displayed in conversation history
### Changed
- **Assistant Chat session screen — UX overhaul** (#158, "impeccable" pass) — removed the duplicate "Suggested checks" chip strip in favor of the TaskLane as the single source of truth; added an inline `Next steps · N pending` cue above the latest action-bearing AI bubble; consolidated the session header to two visible primary actions (Resolve + Escalate) plus a kebab for Context / New Ticket / Update Ticket / Pause; centered the messages column to `max-w-3xl` to match the composer; unified chat-bubble radii to `rounded-xl`; dropped every banned decoration (3px side stripes, gradient surfaces, accent borderTop, backdrop blur, pulse rings, bordered avatar boxes) for a single decoration channel per surface; unified 14 distinct text sizes into a 5-step scale (10/11/12/13/14px); split the ambiguous `MessageCircleQuestion` icon into `Pencil` (write affordance for question Answer CTA) and `HelpCircle` (universal help icon for the per-check explainer); audited and dropped redundant `font-sans` classes across the screen.
- **Suggested-fix banner ↔ script panel are now linked** (#158) — collapsing the ProposalBanner now also hides the InlineNoTemplateDialog / TemplateMatchPanel; dismissing the banner closes both surfaces. Recording any outcome on a fix (Dismiss, It worked, Didn't work, Mark partial, Waiting to verify) closes the script panel alongside the banner state transition.
- **Edit Procedure page** — layout overhaul and color system refinements for better visual hierarchy
- **Flows sidebar navigation** — collapsed to reduce visual noise; session recovery removed from library view
- **Account settings page** — audit fixes for improved consistency and usability
@@ -52,6 +61,7 @@ All notable changes to ResolutionFlow are documented here.
- **Tenant data boundaries** — all session and tree endpoints now return 404 (not 403) for cross-tenant access attempts to avoid confirming resource existence
### Fixed
- **`ParameterizationPreview` over-highlight on short parameter values** (#158) — the tokenizer matched highlight values via raw substring with no word-boundary check, so a single-char value like `"D"` (a drive letter) lit up every capital D in identifiers like `Get-ADUser`, `Add-Type`, `Disable-`. Added a word-boundary guard that's conditional on whether the value itself starts/ends with a word character, so values with leading/trailing punctuation (e.g. `"D:\\Folder"`) still match cleanly when adjacent to whitespace.
- **CRITICAL: Copilot tree query isolation** (#131) — user could access any tree UUID if known, exposing full tree structure to AI. Now scoped to current account with 404 for inaccessible trees.
- **AI session search isolation** — search endpoint leaked other users' sessions via OR(user_id, account_id). Now restricted to current user only.
- **Analytics endpoint isolation** — GET `/analytics/flows/{tree_id}` exposed session counts for any tree UUID. Now returns 404 if tree doesn't belong to requesting account.

View File

@@ -2,7 +2,7 @@
> **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:** April 12, 2026
> **Last Updated:** May 1, 2026
---
@@ -10,6 +10,14 @@
---
## Recently shipped (post-0.1.0.0)
- **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.
---
## What's Complete
### Core Platform

View File

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

View File

@@ -11,7 +11,7 @@
## Quick Start
```bash
# Prerequisites: Docker, Python 3.11+, Node.js 20+
# Prerequisites: Docker, Python 3.12, Node.js 20+
# Start PostgreSQL
docker start patherly_postgres

View File

@@ -21,4 +21,12 @@ ANTHROPIC_API_KEY=
VOYAGE_API_KEY=
# ConnectWise PSA Integration
CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
# Stripe
# Test keys from Stripe Dashboard → Developers → API keys (with Test mode toggled on).
# Webhook secret for local dev: from `stripe listen --forward-to localhost:8000/api/v1/webhooks/stripe`.
# When unset, app/core/config.py:stripe_enabled returns False and Stripe code paths short-circuit.
STRIPE_SECRET_KEY=sk_test_
STRIPE_PUBLISHABLE_KEY=pk_test_
STRIPE_WEBHOOK_SECRET=whsec_

View File

@@ -0,0 +1,30 @@
"""account_invites add revoked_at and email_sent_at
Revision ID: 2aa73d3231c2
Revises: e1af7ab57ceb
Create Date: 2026-05-06 07:28:28.514384
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2aa73d3231c2'
down_revision: Union[str, None] = 'e1af7ab57ceb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("account_invites", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("account_invites", sa.Column("email_sent_at", sa.DateTime(timezone=True), nullable=True))
op.create_index("ix_account_invites_revoked_at", "account_invites", ["revoked_at"])
def downgrade() -> None:
op.drop_index("ix_account_invites_revoked_at", table_name="account_invites")
op.drop_column("account_invites", "email_sent_at")
op.drop_column("account_invites", "revoked_at")

View File

@@ -0,0 +1,28 @@
"""users add role_at_signup and onboarding_step_completed
Revision ID: 58e3caaa6269
Revises: 5bb055a1593e
Create Date: 2026-05-06 07:25:16.780761
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '58e3caaa6269'
down_revision: Union[str, None] = '5bb055a1593e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("role_at_signup", sa.String(50), nullable=True))
op.add_column("users", sa.Column("onboarding_step_completed", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("users", "onboarding_step_completed")
op.drop_column("users", "role_at_signup")

View File

@@ -0,0 +1,47 @@
"""users password_hash nullable
Revision ID: 5bb055a1593e
Revises: b1fad5ddf357
Create Date: 2026-05-06 07:23:21.480252
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5bb055a1593e'
down_revision: Union[str, None] = 'b1fad5ddf357'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.alter_column(
"users",
"password_hash",
existing_type=sa.String(255),
nullable=True,
)
def downgrade() -> None:
# NOTE: downgrade is non-trivial if any OAuth-only users exist.
# This downgrade fails fast in that case rather than corrupting data.
conn = op.get_bind()
null_count = conn.execute(
sa.text("SELECT COUNT(*) FROM users WHERE password_hash IS NULL")
).scalar()
if null_count and null_count > 0:
raise RuntimeError(
f"Cannot downgrade: {null_count} OAuth-only users have NULL password_hash. "
"Set passwords or delete those rows before downgrading."
)
op.alter_column(
"users",
"password_hash",
existing_type=sa.String(255),
nullable=False,
)

View File

@@ -0,0 +1,39 @@
"""add oauth_identities
Revision ID: b1fad5ddf357
Revises: c0f3a4b7e91d
Create Date: 2026-05-06 07:17:11.374555
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = 'b1fad5ddf357'
down_revision: Union[str, None] = 'c0f3a4b7e91d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"oauth_identities",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("provider", sa.String(20), nullable=False),
sa.Column("provider_subject", sa.String(255), nullable=False),
sa.Column("provider_email_at_link", sa.String(255), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("provider", "provider_subject", name="uq_oauth_identities_provider_subject"),
)
op.create_index("ix_oauth_identities_user_id", "oauth_identities", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_oauth_identities_user_id", table_name="oauth_identities")
op.drop_table("oauth_identities")

View File

@@ -0,0 +1,47 @@
"""subscriptions pilot complimentary backfill
This migration converts existing pilot/dev accounts to permanent complimentary
Pro per the self-serve signup spec section 5. Forward-only; downgrade is
prohibited because original status is not preserved.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "c6cbfc534fad"
down_revision: Union[str, None] = "c982a3fc4bf1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Set status='complimentary' and plan='pro' for all existing accounts that
don't have a canceled or past_due subscription. Pilot users transition to
permanent complimentary Pro per spec section 5.
Forward-only — does not preserve original status values."""
conn = op.get_bind()
# Update existing rows
conn.execute(sa.text("""
UPDATE subscriptions
SET status = 'complimentary', plan = 'pro',
current_period_end = NULL, current_period_start = NULL,
updated_at = now()
WHERE status NOT IN ('canceled', 'past_due')
"""))
# Backfill: any account without a Subscription row gets one
conn.execute(sa.text("""
INSERT INTO subscriptions (id, account_id, plan, status, cancel_at_period_end, created_at, updated_at)
SELECT gen_random_uuid(), a.id, 'pro', 'complimentary', false, now(), now()
FROM accounts a
WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.account_id = a.id)
"""))
def downgrade() -> None:
raise RuntimeError(
"Cannot downgrade: original subscription state is not preserved. "
"Restore from backup if needed."
)

View File

@@ -0,0 +1,45 @@
"""add stripe_events
Revision ID: c982a3fc4bf1
Revises: f7da3f93b519
Create Date: 2026-05-06 07:32:08.027633
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
# revision identifiers, used by Alembic.
revision: str = 'c982a3fc4bf1'
down_revision: Union[str, None] = 'f7da3f93b519'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"stripe_events",
sa.Column("id", sa.String(length=255), primary_key=True, nullable=False),
sa.Column("event_type", sa.String(length=100), nullable=False),
sa.Column(
"processed_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"payload_excerpt",
JSONB,
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
)
op.create_index("ix_stripe_events_event_type", "stripe_events", ["event_type"])
def downgrade() -> None:
op.drop_index("ix_stripe_events_event_type", table_name="stripe_events")
op.drop_table("stripe_events")

View File

@@ -0,0 +1,28 @@
"""accounts add wizard columns
Revision ID: e1af7ab57ceb
Revises: 58e3caaa6269
Create Date: 2026-05-06 07:27:15.755518
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e1af7ab57ceb'
down_revision: Union[str, None] = '58e3caaa6269'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("accounts", sa.Column("team_size_bucket", sa.String(20), nullable=True))
op.add_column("accounts", sa.Column("primary_psa", sa.String(20), nullable=True))
def downgrade() -> None:
op.drop_column("accounts", "primary_psa")
op.drop_column("accounts", "team_size_bucket")

View File

@@ -0,0 +1,41 @@
"""add plan_billing
Revision ID: f236a91224d0
Revises: 2aa73d3231c2
Create Date: 2026-05-06 07:30:06.807887
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f236a91224d0'
down_revision: Union[str, None] = '2aa73d3231c2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"plan_billing",
sa.Column("plan", sa.String(50), sa.ForeignKey("plan_limits.plan"), primary_key=True),
sa.Column("display_name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("monthly_price_cents", sa.Integer(), nullable=True),
sa.Column("annual_price_cents", sa.Integer(), nullable=True),
sa.Column("stripe_product_id", sa.String(255), nullable=True),
sa.Column("stripe_monthly_price_id", sa.String(255), nullable=True),
sa.Column("stripe_annual_price_id", sa.String(255), nullable=True),
sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("plan_billing")

View File

@@ -0,0 +1,57 @@
"""add sales_leads
Revision ID: f7da3f93b519
Revises: f236a91224d0
Create Date: 2026-05-06 07:31:39.533305
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = 'f7da3f93b519'
down_revision: Union[str, None] = 'f236a91224d0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"sales_leads",
sa.Column("id", UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("company", sa.String(length=255), nullable=False),
sa.Column("team_size", sa.String(length=20), nullable=True),
sa.Column("message", sa.Text(), nullable=True),
sa.Column("source", sa.String(length=50), nullable=False),
sa.Column("posthog_distinct_id", sa.String(length=255), nullable=True),
sa.Column(
"status",
sa.String(length=20),
nullable=False,
server_default=sa.text("'new'"),
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_sales_leads_email", "sales_leads", ["email"])
def downgrade() -> None:
op.drop_index("ix_sales_leads_email", table_name="sales_leads")
op.drop_table("sales_leads")

View File

@@ -83,11 +83,12 @@ async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> User:
"""Ensure user is active (not disabled). Auto-downgrades expired trials.
Enforces must_change_password — blocks all routes except allowlist.
"""Ensure user is active (not disabled). Enforces must_change_password —
blocks all routes except allowlist.
Uses get_admin_db: runs before require_tenant_context sets the ContextVar,
so tenant-scoped tables (subscriptions) would return 0 rows via app role.
Trial expiry enforcement now happens via require_active_subscription in
individual routers, NOT here. This dep no longer mutates Subscription
state.
"""
if not current_user.is_active:
raise HTTPException(
@@ -106,26 +107,6 @@ async def get_current_active_user(
# Set Sentry user context for error attribution
sentry_sdk.set_user({"id": str(current_user.id), "email": current_user.email})
# Lightweight trial expiry check
if current_user.account_id:
from app.models.subscription import Subscription
from datetime import datetime, timezone
result = await db.execute(
select(Subscription).where(Subscription.account_id == current_user.account_id)
)
subscription = result.scalar_one_or_none()
if (
subscription
and subscription.status == "trialing"
and subscription.current_period_end
and subscription.current_period_end < datetime.now(timezone.utc)
):
subscription.plan = "free"
subscription.status = "active"
subscription.current_period_end = None
subscription.current_period_start = None
await db.commit()
return current_user
@@ -241,3 +222,117 @@ async def require_admin_db(
the user object is needed in the handler.
"""
return db
_SUBSCRIPTION_GUARD_ALLOWLIST = {
"/api/v1/auth/me",
"/api/v1/auth/logout",
"/api/v1/auth/password/change",
"/api/v1/auth/email/send-verification",
"/api/v1/auth/email/verify",
"/api/v1/billing/state",
"/api/v1/billing/checkout-session",
"/api/v1/billing/portal-session",
"/api/v1/users/me",
"/api/v1/users/me/onboarding-step",
"/api/v1/users/me/onboarding-dismiss-rest",
}
async def require_active_subscription(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
):
"""Returns the Subscription row when the account has access; raises 402
when locked. Mounted on routers requiring Pro entitlement.
'Locked' = (trialing AND current_period_end < now()) OR
(canceled OR incomplete OR no subscription).
Active states: active, complimentary, trialing-with-time-remaining, past_due.
"""
if request.url.path in _SUBSCRIPTION_GUARD_ALLOWLIST:
return None
from app.models.subscription import Subscription
from datetime import datetime, timezone
result = await db.execute(
select(Subscription).where(Subscription.account_id == current_user.account_id)
)
sub = result.scalar_one_or_none()
if sub is None:
raise HTTPException(
status_code=402,
detail={"error": "no_subscription", "upgrade_url": "/account/billing/select-plan"},
)
now = datetime.now(timezone.utc)
is_live = (
sub.status in ("active", "complimentary", "past_due")
or (
sub.status == "trialing"
and sub.current_period_end is not None
and sub.current_period_end > now
)
)
if not is_live:
raise HTTPException(
status_code=402,
detail={
"error": "subscription_inactive",
"status": sub.status,
"plan": sub.plan,
"current_period_end": sub.current_period_end.isoformat() if sub.current_period_end else None,
"upgrade_url": "/account/billing/select-plan",
},
)
return sub
_EMAIL_VERIFICATION_ALLOWLIST = {
"/api/v1/auth/me",
"/api/v1/auth/logout",
"/api/v1/auth/email/send-verification",
"/api/v1/auth/email/verify",
"/api/v1/auth/password/change",
"/api/v1/users/me",
"/api/v1/users/me/onboarding-step",
"/api/v1/users/me/onboarding-dismiss-rest",
"/api/v1/billing/state",
"/api/v1/billing/checkout-session",
"/api/v1/billing/portal-session",
}
VERIFICATION_GRACE_DAYS = 7
async def require_verified_email_after_grace(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Enforces 'this user has verified email OR is still in 7-day grace.'
OAuth signups bypass cleanly because /auth/{google,microsoft}/callback
sets users.email_verified_at = now() (provider-attested)."""
from datetime import datetime, timezone, timedelta
if request.url.path in _EMAIL_VERIFICATION_ALLOWLIST:
return
if current_user.email_verified_at is not None:
return
grace_ends = current_user.created_at + timedelta(days=VERIFICATION_GRACE_DAYS)
if datetime.now(timezone.utc) < grace_ends:
return
raise HTTPException(
status_code=403,
detail={
"error": "email_not_verified",
"grace_ended_at": grace_ends.isoformat(),
"resend_url": "/api/v1/auth/email/send-verification",
},
)

View File

@@ -0,0 +1,54 @@
"""Public endpoint for resolving an account invite code into display info.
Mounted as a public route (no tenant context, no auth) — used by the
/accept-invite page on the frontend so an invitee can see what account they
are about to join before they sign up. Uses the BYPASSRLS admin session
factory because account_invites is account-scoped under Phase 4 RLS but the
caller has no tenant identity yet.
"""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.core.admin_database import get_admin_db
from app.models.account_invite import AccountInvite
from app.schemas.oauth import InviteLookupResponse
router = APIRouter(prefix="/accounts", tags=["account-invite-lookup"])
@router.get("/invites/{code}/lookup", response_model=InviteLookupResponse)
async def lookup_invite(
code: str,
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> InviteLookupResponse:
"""Return minimal display data for a valid (unused, unexpired, not revoked)
invite. Returns 404 with `invite_invalid_or_expired_or_revoked` for any
invalid state — the AcceptInvitePage shows a single "ask the inviter to
resend" message regardless of which condition failed (anti-enumeration)."""
result = await db.execute(
select(AccountInvite)
.where(AccountInvite.code == code)
.options(
joinedload(AccountInvite.account),
joinedload(AccountInvite.invited_by),
)
)
invite = result.scalar_one_or_none()
if invite is None or not invite.is_valid:
raise HTTPException(
status_code=404,
detail={"error": "invite_invalid_or_expired_or_revoked"},
)
return InviteLookupResponse(
account_name=invite.account.name,
inviter_name=invite.invited_by.name,
invited_email=invite.email,
role=invite.role,
)

View File

@@ -19,7 +19,7 @@ from app.models.account_invite import AccountInvite
from app.models.account_settings import AccountSettings
from app.models.subscription import Subscription
from app.models.user import User
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, TransferOwnershipRequest
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, AccountInviteBulkCreate, AccountInviteBulkResponse, TransferOwnershipRequest
from app.schemas.subscription import SubscriptionResponse, PlanLimitsResponse, UsageResponse, SubscriptionDetails
from app.schemas.user import UserResponse, AccountRoleUpdate
from app.core.security import verify_password
@@ -260,7 +260,7 @@ async def create_invite(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Create an invite to join this account (owner only)."""
"""Create an invite to join this account (owner only). Sends invite email."""
code = secrets.token_urlsafe(16)
expires_at = None
@@ -276,11 +276,109 @@ async def create_invite(
expires_at=expires_at,
)
db.add(invite)
await db.flush()
# Lookup account name for email
account_result = await db.execute(
select(Account).where(Account.id == current_user.account_id)
)
account = account_result.scalar_one()
# Send invite email — non-blocking on failure (function returns False on error)
email_sent = await EmailService.send_account_invite_email(
to_email=invite.email,
code=code,
account_name=account.name,
role=invite.role,
)
if email_sent:
invite.email_sent_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(invite)
return invite
@router.post("/me/invites/bulk", response_model=AccountInviteBulkResponse, status_code=status.HTTP_201_CREATED)
async def create_invites_bulk(
payload: AccountInviteBulkCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Create multiple invites in one call (wizard step 3 supports up to N).
Per-row failures are returned in `failed`; successes in `created`."""
# Lookup account once for email rendering
account_result = await db.execute(
select(Account).where(Account.id == current_user.account_id)
)
account = account_result.scalar_one()
created: list[AccountInvite] = []
failed: list[dict] = []
for invite_data in payload.invites:
try:
code = secrets.token_urlsafe(16)
expires_at = None
if invite_data.expires_in_days:
expires_at = datetime.now(timezone.utc) + timedelta(days=invite_data.expires_in_days)
invite = AccountInvite(
account_id=current_user.account_id,
invited_by_id=current_user.id,
email=invite_data.email,
code=code,
role=invite_data.role,
expires_at=expires_at,
)
db.add(invite)
await db.flush()
email_sent = await EmailService.send_account_invite_email(
to_email=invite.email,
code=code,
account_name=account.name,
role=invite.role,
)
if email_sent:
invite.email_sent_at = datetime.now(timezone.utc)
created.append(invite)
except Exception as e:
failed.append({"email": invite_data.email, "error": str(e)})
await db.commit()
for inv in created:
await db.refresh(inv)
return AccountInviteBulkResponse(created=created, failed=failed)
@router.delete("/me/invites/{invite_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_invite(
invite_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Soft-revoke an invitation by setting revoked_at. Idempotent on already-
revoked invites; rejects already-accepted invites."""
result = await db.execute(
select(AccountInvite).where(
AccountInvite.id == invite_id,
AccountInvite.account_id == current_user.account_id,
)
)
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
if invite.is_revoked:
return None # idempotent
if invite.is_used:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot revoke an accepted invite")
invite.revoked_at = datetime.now(timezone.utc)
await db.commit()
return None
@router.post("/me/invites/{invite_id}/resend", response_model=AccountInviteResponse)
async def resend_invite(
invite_id: UUID,

View File

@@ -8,34 +8,101 @@ from app.core.database import get_db
from app.core.audit import log_audit
from app.models.user import User
from app.models.plan_limits import PlanLimits
from app.models.plan_billing import PlanBilling
from app.models.account import Account
from app.models.account_limit_override import AccountLimitOverride
from app.models.subscription import Subscription
from app.schemas.admin import (
PlanLimitResponse, PlanLimitUpdate,
PlanLimitResponse, PlanLimitUpdate, PlanLimitWithBillingResponse,
AccountOverrideCreate, AccountOverrideUpdate, AccountOverrideResponse,
)
from app.api.deps import require_admin
from app.services.billing import BillingService
router = APIRouter(prefix="/admin", tags=["admin-plan-limits"])
@router.get("/plan-limits", response_model=list[PlanLimitResponse])
# Fields on PlanLimitUpdate that map to plan_billing (not plan_limits).
_PLAN_BILLING_FIELDS = (
"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",
)
# Subset of _PLAN_BILLING_FIELDS that are NOT NULL on the PlanBilling model.
# These are Optional[...] on PlanLimitUpdate, so a caller sending an explicit
# null for any of them would otherwise trigger a NOT NULL violation at commit.
_PLAN_BILLING_NOT_NULL_FIELDS = frozenset({
"display_name",
"is_public",
"is_archived",
"sort_order",
})
def _merge_plan_with_billing(
plan: PlanLimits, billing: PlanBilling | None
) -> PlanLimitWithBillingResponse:
"""Build a merged response. Billing fields are None when no plan_billing row
exists for the plan."""
payload = {
"plan": plan.plan,
"max_trees": plan.max_trees,
"max_sessions_per_month": plan.max_sessions_per_month,
"max_users": plan.max_users,
"custom_branding": plan.custom_branding,
"priority_support": plan.priority_support,
"export_formats": plan.export_formats or [],
}
if billing is not None:
payload.update({
"display_name": billing.display_name,
"description": billing.description,
"monthly_price_cents": billing.monthly_price_cents,
"annual_price_cents": billing.annual_price_cents,
"stripe_product_id": billing.stripe_product_id,
"stripe_monthly_price_id": billing.stripe_monthly_price_id,
"stripe_annual_price_id": billing.stripe_annual_price_id,
"is_public": billing.is_public,
"is_archived": billing.is_archived,
"sort_order": billing.sort_order,
})
return PlanLimitWithBillingResponse(**payload)
@router.get("/plan-limits", response_model=list[PlanLimitWithBillingResponse])
async def list_plan_limits(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all plan limit configurations."""
result = await db.execute(select(PlanLimits))
return result.scalars().all()
"""List all plan limit configurations, merged with plan_billing fields
where present. Plans without a plan_billing row return None for the
billing fields."""
rows = (await db.execute(
select(PlanLimits, PlanBilling)
.outerjoin(PlanBilling, PlanLimits.plan == PlanBilling.plan)
)).all()
return [_merge_plan_with_billing(pl, pb) for pl, pb in rows]
@router.put("/plan-limits", response_model=PlanLimitResponse)
@router.put("/plan-limits", response_model=PlanLimitWithBillingResponse)
async def update_plan_limits(
data: PlanLimitUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update a plan's limits."""
"""Update a plan's limits and (if any plan_billing field is included)
upsert the matching plan_billing row in the same transaction. After
commit, invalidates the in-process billing cache for accounts on this
plan (currently a no-op — see BillingService.invalidate_billing_cache).
"""
result = await db.execute(select(PlanLimits).where(PlanLimits.plan == data.plan))
plan = result.scalar_one_or_none()
if not plan:
@@ -48,10 +115,50 @@ async def update_plan_limits(
plan.priority_support = data.priority_support
plan.export_formats = data.export_formats
await log_audit(db, current_user.id, "plan_limits.update", "plan_limits", details={"plan": data.plan})
# Did the request include any plan_billing field? (Pydantic gives us
# `model_fields_set` to distinguish "user passed null" from "field omitted".)
billing_fields_set = data.model_fields_set & set(_PLAN_BILLING_FIELDS)
billing: PlanBilling | None = None
if billing_fields_set:
billing = (await db.execute(
select(PlanBilling).where(PlanBilling.plan == data.plan)
)).scalar_one_or_none()
if billing is None:
# Create. display_name is required on the model — derive from the
# plan name when the caller didn't supply one (e.g. "pro" → "Pro").
display_name = data.display_name or data.plan.capitalize()
billing = PlanBilling(plan=data.plan, display_name=display_name)
db.add(billing)
# Apply only the fields the caller actually included. Allows partial
# updates without clobbering existing values.
for field in billing_fields_set:
value = getattr(data, field)
if value is None and field in _PLAN_BILLING_NOT_NULL_FIELDS:
# Don't NULL out a NOT NULL column on update.
continue
setattr(billing, field, value)
await log_audit(
db, current_user.id, "plan_limits.update", "plan_limits",
details={"plan": data.plan, "updated_billing": bool(billing_fields_set)},
)
await db.commit()
await db.refresh(plan)
return plan
if billing is not None:
await db.refresh(billing)
# Invalidate any in-process billing cache for accounts on this plan.
# TODO: invalidate app.state.billing_cache when added.
account_ids = [
row[0] for row in (await db.execute(
select(Subscription.account_id).where(Subscription.plan == data.plan)
)).all()
]
await BillingService.invalidate_billing_cache(account_ids)
return _merge_plan_with_billing(plan, billing)
@router.get("/account-overrides", response_model=list[AccountOverrideResponse])

View File

@@ -1,3 +1,4 @@
import logging
import secrets
import string
from datetime import datetime, timezone, timedelta
@@ -41,11 +42,21 @@ from app.core.email import EmailService
from app.api.deps import get_current_active_user, get_refresh_token_payload
from app.core.audit import log_audit
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["authentication"])
async def _store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) -> None:
"""Decode a refresh token JWT and store its hash in the database."""
async def store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) -> None:
"""Decode a refresh token JWT and store its hash in the database.
Module-public so OAuth callback endpoints (and any future token-issuing
surface) can register the JTI in the ``refresh_tokens`` table the same
way ``/auth/login`` does. Without this the first ``/auth/refresh`` call
will reject the token as "revoked" because no row exists.
Caller is responsible for committing the session.
"""
payload = decode_token(refresh_token_str)
if payload and payload.get("jti"):
token_record = RefreshToken(
@@ -62,6 +73,22 @@ def _generate_display_code() -> str:
return ''.join(secrets.choice(chars) for _ in range(8))
async def _reject_if_oauth_only(db: AsyncSession, user) -> None:
"""If the user has no password_hash, raise 400 with a list of linked
providers so the client can redirect them to the right OAuth flow."""
if user is None or user.password_hash is not None:
return
from app.models.oauth_identity import OAuthIdentity
result = await db.execute(
select(OAuthIdentity.provider).where(OAuthIdentity.user_id == user.id)
)
providers = [row for row in result.scalars().all()]
raise HTTPException(
status_code=400,
detail={"error": "use_oauth_provider", "providers": providers},
)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("3/minute")
async def register(
@@ -108,10 +135,24 @@ async def register(
detail="Account invite code has expired"
)
if account_invite_record.email.lower() != user_data.email.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "invite_email_mismatch"},
)
# Validate platform invite code (skip if account invite was provided)
invite_code_record = None
if not account_invite_record:
if settings.REQUIRE_INVITE_CODE and not user_data.invite_code:
# When SELF_SERVE_ENABLED is on, the platform invite gate is bypassed
# entirely — public self-serve signup is the whole point. The
# invite_code field stays in the schema for backward compatibility
# and so paid/trial-bearing codes still apply when supplied.
if (
settings.REQUIRE_INVITE_CODE
and not settings.SELF_SERVE_ENABLED
and not user_data.invite_code
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invite code is required"
@@ -195,26 +236,30 @@ async def register(
# Now set account owner and create subscription
new_account.owner_id = new_user.id
# Apply plan/trial from invite code if present
sub_plan = "free"
sub_status = "active"
period_start = None
period_end = None
if invite_code_record and invite_code_record.assigned_plan:
# Plan/trial driven by platform invite code (existing pilot flow)
sub_plan = invite_code_record.assigned_plan
sub_status = "active"
period_start = None
period_end = None
if invite_code_record.trial_duration_days:
sub_status = "trialing"
period_start = datetime.now(timezone.utc)
period_end = period_start + timedelta(days=invite_code_record.trial_duration_days)
new_subscription = Subscription(
account_id=new_account.id,
plan=sub_plan,
status=sub_status,
current_period_start=period_start,
current_period_end=period_end,
)
db.add(new_subscription)
db.add(Subscription(
account_id=new_account.id,
plan=sub_plan,
status=sub_status,
current_period_start=period_start,
current_period_end=period_end,
))
else:
# New self-serve shop — start the standard Pro trial.
# start_trial commits internally; flush our pending User/Account changes
# first so the FK is satisfied.
await db.flush()
from app.services.billing import BillingService
await BillingService.start_trial(db, new_account.id)
# Mark platform invite code as used
if invite_code_record:
@@ -224,6 +269,34 @@ async def register(
await db.commit()
await db.refresh(new_user)
# Auto-send verification email for newly-registered users.
# Skip silently if verification already done (shouldn't happen for fresh
# users, but defensive).
if new_user.email_verified_at is None:
verification_enabled = await SettingsManager.get(
"email_verification_enabled", db, default=True
)
if verification_enabled:
try:
raw_token = create_email_verification_token(str(new_user.id))
payload = decode_token(raw_token)
if payload and payload.get("jti"):
token_record = EmailVerificationToken(
token_hash=hash_token(payload["jti"]),
user_id=new_user.id,
expires_at=datetime.fromtimestamp(payload["exp"], tz=timezone.utc),
)
db.add(token_record)
await db.commit()
verification_url = f"{settings.FRONTEND_URL}/verify-email?token={raw_token}"
await EmailService.send_email_verification_email(
to_email=new_user.email,
verification_url=verification_url,
)
except Exception as e:
logger.warning("verification email send failed for %s: %s", new_user.email, e)
return new_user
@@ -239,6 +312,7 @@ async def login(
result = await db.execute(select(User).where(User.email == form_data.username))
user = result.scalar_one_or_none()
await _reject_if_oauth_only(db, user)
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -254,7 +328,7 @@ async def login(
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
# Store refresh token hash in DB
await _store_refresh_token(db, refresh_token_str, user.id)
await store_refresh_token(db, refresh_token_str, user.id)
await db.commit()
return Token(
@@ -276,6 +350,7 @@ async def login_json(
result = await db.execute(select(User).where(User.email == credentials.email))
user = result.scalar_one_or_none()
await _reject_if_oauth_only(db, user)
if not user or not verify_password(credentials.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -288,7 +363,7 @@ async def login_json(
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
# Store refresh token hash in DB
await _store_refresh_token(db, refresh_token_str, user.id)
await store_refresh_token(db, refresh_token_str, user.id)
await db.commit()
return Token(
@@ -346,7 +421,7 @@ async def refresh_token(
new_refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
# Store new refresh token
await _store_refresh_token(db, new_refresh_token_str, user.id)
await store_refresh_token(db, new_refresh_token_str, user.id)
await db.commit()
return Token(
@@ -441,6 +516,7 @@ async def change_password(
db: Annotated[AsyncSession, Depends(get_admin_db)]
):
"""Change the current user's password."""
await _reject_if_oauth_only(db, current_user)
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -484,7 +560,7 @@ async def forgot_password(
result = await db.execute(select(User).where(User.email == data.email))
user = result.scalar_one_or_none()
if user:
if user and user.password_hash is not None:
# Create reset token JWT
raw_token = create_password_reset_token(str(user.id))
payload = decode_token(raw_token)

View File

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

View File

@@ -0,0 +1,76 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_active_user
from app.core.admin_database import get_admin_db
from app.core.config import settings
from app.models.account import Account
from app.models.user import User
from app.schemas.billing import (
BillingPortalSessionResponse,
BillingStateResponse,
CheckoutSessionCreate,
CheckoutSessionResponse,
)
from app.services.billing import BillingService
router = APIRouter(prefix="/billing", tags=["billing"])
@router.post("/checkout-session", response_model=CheckoutSessionResponse)
async def create_checkout_session(
payload: CheckoutSessionCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> CheckoutSessionResponse:
account = (await db.execute(
select(Account).where(Account.id == current_user.account_id)
)).scalar_one()
url = await BillingService.create_checkout_session(
db=db,
account=account,
plan=payload.plan,
seats=payload.seats,
billing_interval=payload.billing_interval,
success_url=f"{settings.FRONTEND_URL}/account/billing?success=1",
cancel_url=f"{settings.FRONTEND_URL}/account/billing/select-plan",
)
return CheckoutSessionResponse(url=url)
@router.get("/state", response_model=BillingStateResponse)
async def get_billing_state(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> BillingStateResponse:
account = (await db.execute(
select(Account).where(Account.id == current_user.account_id)
)).scalar_one()
state = await BillingService.get_billing_state(db, account)
return BillingStateResponse(**state)
@router.get("/portal-session", response_model=BillingPortalSessionResponse)
async def get_billing_portal_session(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> BillingPortalSessionResponse:
"""Return a Stripe-hosted Customer Portal URL for the account so the user
can update card / cancel. Allowlisted from the subscription + email-verify
guards (a canceled or unverified-past-grace user must still be able to
update billing)."""
if not settings.stripe_enabled:
raise HTTPException(status_code=503, detail={"error": "stripe_not_configured"})
account = (await db.execute(
select(Account).where(Account.id == current_user.account_id)
)).scalar_one()
try:
url = await BillingService.open_customer_portal(account)
except ValueError:
raise HTTPException(status_code=400, detail={"error": "no_stripe_customer"})
return BillingPortalSessionResponse(url=url)

View File

@@ -0,0 +1,40 @@
"""Public runtime configuration endpoint.
GET /api/v1/config/public
Returns the small set of runtime flags the frontend needs at app load
to decide whether to render the self-serve signup flow and which OAuth
buttons to show. No authentication required.
The response model lives in `app.schemas.config` so it can be reused by
frontend codegen and other call sites if needed.
"""
from __future__ import annotations
from fastapi import APIRouter
from app.core.config import settings
from app.schemas.config import PublicConfigResponse
router = APIRouter(prefix="/config", tags=["config"])
@router.get("/public", response_model=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.
"""
providers: list[str] = []
if settings.GOOGLE_CLIENT_ID:
providers.append("google")
if settings.MS_CLIENT_ID:
providers.append("microsoft")
return PublicConfigResponse(
self_serve_enabled=settings.SELF_SERVE_ENABLED,
oauth_providers=providers,
)

View File

@@ -0,0 +1,231 @@
import secrets
import string
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.endpoints.auth import store_refresh_token
from app.core.admin_database import get_admin_db
from app.core.config import settings
from app.core.security import create_access_token, create_refresh_token
from app.models.account import Account
from app.models.account_invite import AccountInvite
from app.models.oauth_identity import OAuthIdentity
from app.models.user import User
from app.schemas.oauth import OAuthCallbackPayload, OAuthCallbackResponse
from app.services.billing import BillingService
from app.services.oauth_providers import (
google_exchange_code,
microsoft_exchange_code,
OAuthProfile,
)
router = APIRouter(prefix="/auth", tags=["auth-oauth"])
def _generate_display_code(length: int = 8) -> str:
"""Match the helper used by /auth/register — A-Z + 0-9, length 8."""
alphabet = string.ascii_uppercase + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
async def _sign_in_or_register(
db: AsyncSession,
provider: str,
profile: OAuthProfile,
*,
account_invite_code: str | None = None,
invited_email: str | None = None,
) -> tuple[User, bool]:
"""Returns (user, is_new_user). Idempotent on (provider, provider_subject).
When ``account_invite_code`` is supplied (from the /accept-invite flow),
a brand-new user is created inside the invited account instead of getting
a personal account + Pro trial. Mismatch between the OAuth profile email
and ``invited_email`` raises ``invite_email_mismatch`` per the spec
contract that mirrors the email+password register path.
"""
identity = (
await db.execute(
select(OAuthIdentity).where(
OAuthIdentity.provider == provider,
OAuthIdentity.provider_subject == profile.provider_subject,
)
)
).scalar_one_or_none()
if identity:
user = (
await db.execute(select(User).where(User.id == identity.user_id))
).scalar_one()
return user, False
user = (
await db.execute(select(User).where(User.email == profile.email))
).scalar_one_or_none()
is_new_user = user is None
# If the user arrived via an invite link but already has a ResolutionFlow
# account (e.g., previously signed up with email+password), silently
# linking the OAuth identity to that existing account would bypass the
# invite — they'd stay in their personal account and the invite would
# never be consumed. Fail loud instead so they can sign in and accept the
# invite from the dashboard. The "invited user wants to transfer accounts"
# case is a v2 concern.
if account_invite_code and not is_new_user:
raise HTTPException(
status_code=400,
detail={
"error": "email_already_registered_use_login",
"message": (
"An account already exists for this email. Please sign in "
"instead, then accept the invite from your dashboard."
),
},
)
invite_record: AccountInvite | None = None
if is_new_user and account_invite_code:
# SELECT FOR UPDATE so two concurrent OAuth callbacks can't both
# consume the same invite code.
invite_record = (
await db.execute(
select(AccountInvite)
.where(AccountInvite.code == account_invite_code)
.with_for_update()
)
).scalar_one_or_none()
if invite_record is None or not invite_record.is_valid:
raise HTTPException(
status_code=400,
detail={"error": "invite_invalid_or_expired_or_revoked"},
)
# Verify the OAuth profile email matches what was invited. We compare
# against the invite row directly (source of truth), but also accept
# the client-supplied invited_email as a defensive equality check.
if invite_record.email.lower() != profile.email.lower():
raise HTTPException(
status_code=400,
detail={"error": "invite_email_mismatch"},
)
if invited_email and invited_email.lower() != invite_record.email.lower():
raise HTTPException(
status_code=400,
detail={"error": "invite_email_mismatch"},
)
if is_new_user:
if invite_record is not None:
# Join the invited account directly — no personal account, no
# trial creation.
user = User(
email=profile.email,
name=profile.name,
password_hash=None,
account_id=invite_record.account_id,
account_role=invite_record.role,
role="engineer",
email_verified_at=datetime.now(timezone.utc),
)
db.add(user)
await db.flush()
invite_record.accepted_by_id = user.id
invite_record.used_at = datetime.now(timezone.utc)
await db.flush()
else:
account = Account(
name=f"{profile.name}'s Account",
display_code=_generate_display_code(),
)
db.add(account)
await db.flush()
user = User(
email=profile.email,
name=profile.name,
password_hash=None,
account_id=account.id,
account_role="owner",
role="engineer",
email_verified_at=datetime.now(timezone.utc),
)
db.add(user)
await db.flush()
account.owner_id = user.id
await db.flush()
# start_trial commits internally; flushed account/user above.
await BillingService.start_trial(db, account.id)
db.add(
OAuthIdentity(
user_id=user.id,
provider=provider,
provider_subject=profile.provider_subject,
provider_email_at_link=profile.email,
)
)
await db.commit()
await db.refresh(user)
return user, is_new_user
@router.post("/google/callback", response_model=OAuthCallbackResponse)
async def google_callback(
payload: OAuthCallbackPayload,
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> OAuthCallbackResponse:
if not settings.GOOGLE_CLIENT_ID:
raise HTTPException(status_code=503, detail="Google sign-in not configured")
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/google/callback"
profile = await google_exchange_code(payload.code, redirect_uri)
user, is_new = await _sign_in_or_register(
db,
"google",
profile,
account_invite_code=payload.account_invite_code,
invited_email=payload.invited_email,
)
refresh_token_str = create_refresh_token({"sub": str(user.id)})
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
# reject this token as "revoked" (the rotation logic requires a row to
# mark as used). _sign_in_or_register already committed; this needs a
# second commit.
await store_refresh_token(db, refresh_token_str, user.id)
await db.commit()
return OAuthCallbackResponse(
access_token=create_access_token({"sub": str(user.id)}),
refresh_token=refresh_token_str,
is_new_user=is_new,
)
@router.post("/microsoft/callback", response_model=OAuthCallbackResponse)
async def microsoft_callback(
payload: OAuthCallbackPayload,
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> OAuthCallbackResponse:
if not settings.MS_CLIENT_ID:
raise HTTPException(status_code=503, detail="Microsoft sign-in not configured")
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/microsoft/callback"
profile = await microsoft_exchange_code(payload.code, redirect_uri)
user, is_new = await _sign_in_or_register(
db,
"microsoft",
profile,
account_invite_code=payload.account_invite_code,
invited_email=payload.invited_email,
)
refresh_token_str = create_refresh_token({"sub": str(user.id)})
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
# reject this token as "revoked" (the rotation logic requires a row to
# mark as used). _sign_in_or_register already committed; this needs a
# second commit.
await store_refresh_token(db, refresh_token_str, user.id)
await db.commit()
return OAuthCallbackResponse(
access_token=create_access_token({"sub": str(user.id)}),
refresh_token=refresh_token_str,
is_new_user=is_new,
)

View File

@@ -2,19 +2,24 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_active_user
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.models.account import Account
from app.models.assistant_chat import AssistantChat
from app.models.psa_connection import PsaConnection
from app.models.session import Session
from app.models.tree import Tree
from app.models.user import User
from app.schemas.onboarding import OnboardingStatus
from app.schemas.onboarding import (
OnboardingStatus,
OnboardingStepRequest,
OnboardingStepResponse,
)
router = APIRouter(prefix="/users", tags=["onboarding"])
@@ -85,6 +90,10 @@ async def get_onboarding_status(
)
connected_psa = (psa_q.scalar() or 0) > 0
# New (Phase 2 — Task 41)
email_verified = current_user.email_verified_at is not None
shop_setup_done = (current_user.onboarding_step_completed or 0) >= 1
return OnboardingStatus(
created_flow=created_flow,
ran_session=ran_session,
@@ -94,6 +103,8 @@ async def get_onboarding_status(
connected_psa=connected_psa,
is_team_user=is_team_user,
dismissed=current_user.onboarding_dismissed,
email_verified=email_verified,
shop_setup_done=shop_setup_done,
)
@@ -109,3 +120,98 @@ async def dismiss_onboarding(
# Return updated status (reuse the GET logic)
return await get_onboarding_status(db=db, current_user=current_user)
# ---------------------------------------------------------------------------
# Welcome wizard endpoints (Phase 2)
#
# These persist Step 1/2/3 progress for the post-signup welcome wizard.
# Mounted on /users/me/* (the parent router prefix is /users) so the wizard
# can run before email verification and during trial.
# ---------------------------------------------------------------------------
@router.patch("/me/onboarding-step", response_model=OnboardingStepResponse)
async def patch_onboarding_step(
body: OnboardingStepRequest,
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> OnboardingStepResponse:
"""Persist welcome-wizard progress for the current user.
Contract:
- step=1 + complete writes accounts.name, accounts.team_size_bucket,
users.role_at_signup, then sets users.onboarding_step_completed=1.
- step=2 + complete writes accounts.primary_psa, then sets
users.onboarding_step_completed=2.
- step=3 + complete just sets users.onboarding_step_completed=3
(invites are POSTed separately).
- action="skip" ignores `data` entirely and only advances the step.
- The new step must be >= current onboarding_step_completed (None=>0);
otherwise 400. Idempotent re-PATCH of the same step succeeds.
"""
current_step = current_user.onboarding_step_completed or 0
if body.step < current_step:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "step_cannot_decrease",
"current_step": current_step,
"requested_step": body.step,
},
)
if body.action == "complete" and body.data is not None and body.step in (1, 2):
# Load the user's account for field writes. Step 3 has no data writes.
account_result = await db.execute(
select(Account).where(Account.id == current_user.account_id)
)
account = account_result.scalar_one_or_none()
if account is None:
# Should never happen — user is required to have an account_id.
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="account_not_found",
)
if body.step == 1:
data = body.data
if data.company_name is not None:
account.name = data.company_name
if data.team_size_bucket is not None:
account.team_size_bucket = data.team_size_bucket
if data.role_at_signup is not None:
current_user.role_at_signup = data.role_at_signup
elif body.step == 2:
data = body.data
if data.primary_psa is not None:
account.primary_psa = data.primary_psa
current_user.onboarding_step_completed = body.step
await db.commit()
await db.refresh(current_user)
return OnboardingStepResponse(
onboarding_step_completed=current_user.onboarding_step_completed,
onboarding_dismissed=current_user.onboarding_dismissed,
)
@router.post("/me/onboarding-dismiss-rest", response_model=OnboardingStepResponse)
async def dismiss_onboarding_rest(
db: Annotated[AsyncSession, Depends(get_admin_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> OnboardingStepResponse:
"""Set users.onboarding_dismissed=TRUE — backs the wizard's "Skip the rest" button.
Returns the same shape as the step PATCH so the frontend can update its
local store from a single response.
"""
current_user.onboarding_dismissed = True
await db.commit()
await db.refresh(current_user)
return OnboardingStepResponse(
onboarding_step_completed=current_user.onboarding_step_completed,
onboarding_dismissed=current_user.onboarding_dismissed,
)

View File

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

View File

@@ -0,0 +1,114 @@
"""Public Talk-to-Sales endpoint — no auth required.
POST /api/v1/sales-leads
- Inserts a sales_leads row.
- Fires (best-effort) a notification email to settings.SALES_LEAD_RECIPIENT_EMAIL.
- Emits a server-side PostHog event (best-effort).
- Rate-limited per IP (5/hour).
"""
from __future__ import annotations
import asyncio
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.admin_database import get_admin_db
from app.core.config import settings
from app.core.email import EmailService
from app.core.rate_limit import limiter
from app.models.sales_lead import SalesLead
from app.schemas.sales_lead import SalesLeadCreate, SalesLeadCreateResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/sales-leads", tags=["sales"])
async def _send_notification_email(lead: SalesLead) -> None:
"""Fire-and-forget wrapper. EmailService methods never raise, but we
still wrap in a try/except to defend against future regressions."""
try:
await EmailService.send_sales_lead_notification(
to_email=settings.SALES_LEAD_RECIPIENT_EMAIL,
lead=lead,
)
except Exception:
logger.warning(
"Sales lead notification email failed for lead %s",
lead.id,
exc_info=True,
)
def _capture_posthog_event(lead: SalesLead) -> None:
"""Emit `talk_to_sales_form_submitted` server-side. Best-effort.
Backend PostHog SDK isn't initialized in the project today; this function
is the single instrumentation point so wiring it up later is a one-line
change. The call is wrapped so any future failure can never fail the
request.
"""
try:
# Lazy import — keeps the dependency optional. When the backend
# PostHog client is wired in (likely as `app.core.analytics.posthog`),
# swap the import path here and the event will fire automatically.
try:
from app.core.analytics import posthog # type: ignore[attr-defined]
except ImportError:
logger.debug(
"PostHog server-side capture skipped — client not configured"
)
return
distinct_id = lead.posthog_distinct_id or f"sales_lead:{lead.id}"
posthog.capture(
distinct_id=distinct_id,
event="talk_to_sales_form_submitted",
properties={
"source": lead.source,
"company": lead.company,
"team_size": lead.team_size,
},
)
except Exception:
logger.warning(
"PostHog capture failed for sales lead %s",
lead.id,
exc_info=True,
)
@router.post("", response_model=SalesLeadCreateResponse, status_code=201)
@limiter.limit("5/hour")
async def create_sales_lead(
request: Request,
data: SalesLeadCreate,
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> SalesLeadCreateResponse:
"""Public Talk-to-Sales submission.
Creates a sales_leads row, fires (best-effort) a notification email and a
server-side PostHog event. Rate-limited per IP at 5/hour.
"""
lead = SalesLead(
email=str(data.email).lower(),
name=data.name,
company=data.company,
team_size=data.team_size,
message=data.message,
source=data.source,
posthog_distinct_id=data.posthog_distinct_id,
)
db.add(lead)
await db.commit()
await db.refresh(lead)
# Fire-and-forget: email + analytics. Failures must not fail the request.
asyncio.create_task(_send_notification_email(lead))
_capture_posthog_event(lead)
return SalesLeadCreateResponse(id=lead.id, status="received")

View File

@@ -1,10 +1,10 @@
import logging
from fastapi import APIRouter, Request, HTTPException, status, Depends
from fastapi import APIRouter, Request, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.admin_database import get_admin_db
from app.core.config import settings
from app.core.stripe_handlers import WEBHOOK_HANDLERS
from app.services.billing import BillingService
logger = logging.getLogger(__name__)
@@ -14,49 +14,36 @@ router = APIRouter(prefix="/webhooks", tags=["webhooks"])
@router.post("/stripe")
async def stripe_webhook(
request: Request,
db: AsyncSession = Depends(get_db),
db: AsyncSession = Depends(get_admin_db),
):
"""Handle Stripe webhook events.
"""Stripe webhook handler. Public endpoint; signature verification is the
only gate. Idempotency via stripe_events table.
Returns 200 for all events to prevent Stripe retries.
Actual processing happens only when Stripe is configured.
Returns 200 even when Stripe is not configured — keeps the receiver
permissive for local dev.
"""
if not settings.stripe_enabled:
if not settings.stripe_enabled or not settings.STRIPE_WEBHOOK_SECRET:
return {"status": "ok", "message": "Stripe not configured, event ignored"}
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
if not sig_header:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing stripe-signature header"
)
raise HTTPException(status_code=400, detail="Missing stripe-signature header")
# Verify webhook signature
try:
import stripe
stripe.api_key = settings.STRIPE_SECRET_KEY
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ImportError:
logger.warning("stripe package not installed, cannot verify webhook")
return {"status": "ok", "message": "stripe package not installed"}
except Exception as e:
logger.error("Stripe webhook signature verification failed: %s", e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid signature"
)
logger.warning("stripe webhook bad signature: %s", e)
raise HTTPException(status_code=400, detail="Invalid signature")
event_type = event.get("type", "")
handler = WEBHOOK_HANDLERS.get(event_type)
if handler:
try:
await handler(event, db)
except Exception:
logger.exception("Error handling Stripe event %s", event_type)
return {"status": "ok"}
applied = await BillingService.apply_subscription_event(
db,
event_id=event["id"],
event_type=event["type"],
payload={"data": event["data"]},
)
return {"status": "ok", "applied": applied}

View File

@@ -1,6 +1,10 @@
from fastapi import APIRouter, Depends
from app.api.deps import require_tenant_context
from app.api.deps import (
require_tenant_context,
require_active_subscription,
require_verified_email_after_grace,
)
from app.api.endpoints import (
admin,
admin_audit,
@@ -19,10 +23,13 @@ from app.api.endpoints import (
analytics,
assistant_chat,
auth,
billing,
beta_feedback,
beta_signup,
sales_leads,
branding,
categories,
config as config_endpoints,
copilot,
device_types,
draft_templates,
@@ -36,7 +43,9 @@ from app.api.endpoints import (
maintenance_schedules,
network_diagrams,
notifications,
oauth as oauth_endpoints,
onboarding,
plans_public,
public_templates,
ratings,
scripts,
@@ -62,6 +71,7 @@ from app.api.endpoints import (
uploads,
webhooks,
accounts,
account_invite_lookup,
)
api_router = APIRouter()
@@ -77,12 +87,18 @@ api_router = APIRouter()
# in Phase 1. This will need revisiting in Phase 2 when `users` gets RLS.
# ---------------------------------------------------------------------------
api_router.include_router(auth.router)
api_router.include_router(oauth_endpoints.router)
api_router.include_router(billing.router) # Reachable when subscription locked
api_router.include_router(shared.router) # Public share links (no auth)
api_router.include_router(shares.public_router) # Public session share links (optional auth)
api_router.include_router(beta_signup.router)
api_router.include_router(sales_leads.router) # Talk-to-Sales (no auth, rate-limited)
api_router.include_router(webhooks.router) # Stripe webhook receiver
api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited)
api_router.include_router(survey.router) # Public survey flow (no auth, rate-limited)
api_router.include_router(config_endpoints.router) # Public runtime feature flags
api_router.include_router(account_invite_lookup.router) # Public invite-code lookup for /accept-invite
api_router.include_router(plans_public.router) # Public plan catalog for /pricing page
# ---------------------------------------------------------------------------
# Admin endpoints — super_admin only
@@ -102,23 +118,36 @@ api_router.include_router(admin_survey.router)
api_router.include_router(admin_gallery.router)
# ---------------------------------------------------------------------------
# User-facing endpoints — tenant context required
#
# _tenant_deps: routers that only require an authenticated user inside a
# tenant (auth/account/admin/non-Pro feature surfaces).
# _pro_deps: routers gated behind an active Pro subscription. Adds
# require_active_subscription which raises 402 unless the
# account's Subscription is active/complimentary/past_due or
# trialing-with-time-remaining. Allowlisted paths in deps.py
# bypass the gate for billing/account admin/auth flows.
# ---------------------------------------------------------------------------
_tenant_deps = [Depends(require_tenant_context)]
_pro_deps = [
Depends(require_tenant_context),
Depends(require_active_subscription),
Depends(require_verified_email_after_grace),
]
api_router.include_router(trees.router, dependencies=_tenant_deps)
api_router.include_router(trees.router, dependencies=_pro_deps)
api_router.include_router(sidebar.router, dependencies=_tenant_deps)
api_router.include_router(sessions.router, dependencies=_tenant_deps)
api_router.include_router(sessions.router, dependencies=_pro_deps)
api_router.include_router(invite.router, dependencies=_tenant_deps)
api_router.include_router(categories.router, dependencies=_tenant_deps)
api_router.include_router(tags.router, dependencies=_tenant_deps)
api_router.include_router(folders.router, dependencies=_tenant_deps)
api_router.include_router(step_categories.router, dependencies=_tenant_deps)
api_router.include_router(steps.router, dependencies=_tenant_deps)
api_router.include_router(step_categories.router, dependencies=_pro_deps)
api_router.include_router(steps.router, dependencies=_pro_deps)
api_router.include_router(accounts.router, dependencies=_tenant_deps)
api_router.include_router(shares.router, dependencies=_tenant_deps)
api_router.include_router(tree_markdown.router, dependencies=_tenant_deps)
api_router.include_router(ratings.router, dependencies=_tenant_deps)
api_router.include_router(analytics.router, dependencies=_tenant_deps)
api_router.include_router(analytics.router, dependencies=_pro_deps)
api_router.include_router(target_lists.router, dependencies=_tenant_deps)
api_router.include_router(maintenance_schedules.router, dependencies=_tenant_deps)
api_router.include_router(feedback.router, dependencies=_tenant_deps)
@@ -126,31 +155,31 @@ api_router.include_router(ai_builder.router, dependencies=_tenant_deps)
api_router.include_router(ai_fix.router, dependencies=_tenant_deps)
api_router.include_router(ai_chat.router, dependencies=_tenant_deps)
api_router.include_router(copilot.router, dependencies=_tenant_deps)
api_router.include_router(assistant_chat.router, dependencies=_tenant_deps)
api_router.include_router(assistant_chat.router, dependencies=_pro_deps)
api_router.include_router(tree_transfer.router, dependencies=_tenant_deps)
api_router.include_router(ai_suggestions.router, dependencies=_tenant_deps)
api_router.include_router(kb_accelerator.router, dependencies=_tenant_deps)
api_router.include_router(scripts.router, dependencies=_tenant_deps)
api_router.include_router(integrations.router, dependencies=_tenant_deps)
api_router.include_router(scripts.router, dependencies=_pro_deps)
api_router.include_router(integrations.router, dependencies=_pro_deps)
api_router.include_router(onboarding.router, dependencies=_tenant_deps)
api_router.include_router(branding.router, dependencies=_tenant_deps)
api_router.include_router(supporting_data.router, dependencies=_tenant_deps)
api_router.include_router(network_diagrams.router, dependencies=_tenant_deps)
# session_handoffs queue router must come before ai_sessions to avoid conflict
api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps)
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
api_router.include_router(session_handoffs.queue_router, dependencies=_pro_deps)
api_router.include_router(session_resolutions.router, dependencies=_pro_deps)
# session_facts mounts under /ai-sessions/{id}/facts — register before ai_sessions
# so the {session_id}/facts subpaths take precedence over any future generic catchalls.
api_router.include_router(session_facts.router, dependencies=_tenant_deps)
api_router.include_router(session_suggested_fixes.router, dependencies=_tenant_deps)
api_router.include_router(session_facts.router, dependencies=_pro_deps)
api_router.include_router(session_suggested_fixes.router, dependencies=_pro_deps)
api_router.include_router(draft_templates.router, dependencies=_tenant_deps)
api_router.include_router(ai_sessions.router, dependencies=_tenant_deps)
api_router.include_router(flow_proposals.router, dependencies=_tenant_deps)
api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps)
api_router.include_router(ai_sessions.router, dependencies=_pro_deps)
api_router.include_router(flow_proposals.router, dependencies=_pro_deps)
api_router.include_router(flowpilot_analytics.router, dependencies=_pro_deps)
api_router.include_router(notifications.router, dependencies=_tenant_deps)
api_router.include_router(uploads.router, dependencies=_tenant_deps)
api_router.include_router(script_builder.router, dependencies=_tenant_deps)
api_router.include_router(script_builder.router, dependencies=_pro_deps)
api_router.include_router(beta_feedback.router, dependencies=_tenant_deps)
api_router.include_router(session_branches.router, dependencies=_tenant_deps)
api_router.include_router(session_handoffs.router, dependencies=_tenant_deps)
api_router.include_router(session_branches.router, dependencies=_pro_deps)
api_router.include_router(session_handoffs.router, dependencies=_pro_deps)
api_router.include_router(device_types.router, dependencies=_tenant_deps)

View File

@@ -84,6 +84,7 @@ class Settings(BaseSettings):
RESEND_API_KEY: Optional[str] = None
FROM_EMAIL: str = "ResolutionFlow <invites@resolutionflow.com>"
FEEDBACK_EMAIL: Optional[str] = None
SALES_LEAD_RECIPIENT_EMAIL: str = "sales@resolutionflow.com"
@property
def email_enabled(self) -> bool:
@@ -94,11 +95,12 @@ class Settings(BaseSettings):
STRIPE_SECRET_KEY: Optional[str] = None
STRIPE_PUBLISHABLE_KEY: Optional[str] = None
STRIPE_WEBHOOK_SECRET: Optional[str] = None
SELF_SERVE_ENABLED: bool = False
@property
def stripe_enabled(self) -> bool:
"""Check if Stripe is configured."""
return self.STRIPE_SECRET_KEY is not None and self.STRIPE_WEBHOOK_SECRET is not None
return bool(self.STRIPE_SECRET_KEY)
# AI Flow Builder
ANTHROPIC_API_KEY: Optional[str] = None
@@ -193,6 +195,13 @@ class Settings(BaseSettings):
"""Check if ConnectWise integration is configured."""
return self.CW_CLIENT_ID is not None
# OAuth providers (self-serve signup)
GOOGLE_CLIENT_ID: Optional[str] = None
GOOGLE_CLIENT_SECRET: Optional[str] = None
MS_CLIENT_ID: Optional[str] = None
MS_CLIENT_SECRET: Optional[str] = None
OAUTH_REDIRECT_BASE: str = "http://localhost:5173"
# Monitoring
SENTRY_DSN: Optional[str] = None

View File

@@ -1,6 +1,11 @@
import logging
from typing import TYPE_CHECKING
from app.core.config import settings
if TYPE_CHECKING:
from app.models.sales_lead import SalesLead
logger = logging.getLogger(__name__)
@@ -484,6 +489,99 @@ class EmailService:
logger.exception("Failed to send beta signup notification for %s", signup_email)
return False
@staticmethod
async def send_sales_lead_notification(
to_email: str,
lead: "SalesLead",
) -> bool:
"""Notify the sales recipient about a new Talk-to-Sales submission.
Fire-and-forget. Returns False (and logs) on any failure; never raises.
"""
if not settings.email_enabled:
logger.warning(
"Sales lead email not sent — RESEND_API_KEY not configured (lead %s)",
lead.id,
)
return False
try:
import resend
import html as html_mod
from datetime import datetime, timezone
resend.api_key = settings.RESEND_API_KEY
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
safe_email = html_mod.escape(lead.email)
safe_name = html_mod.escape(lead.name)
safe_company = html_mod.escape(lead.company)
safe_team_size = html_mod.escape(lead.team_size or "")
safe_source = html_mod.escape(lead.source)
safe_message = html_mod.escape(lead.message or "(no message)")
subject = f"[ResolutionFlow Sales] New lead — {safe_company} ({safe_email})"
email_html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">New Sales Lead</p>
</td></tr>
<tr><td style="padding:0 40px 16px;">
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
Source: <strong style="color:#f8fafc;">{safe_source}</strong>
</p>
</td></tr>
<tr><td style="padding:0 40px 16px;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.06);border-radius:12px;">
<tr><td style="padding:16px;">
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Name</p>
<p style="margin:0 0 12px;color:#f8fafc;font-size:16px;font-weight:600;">{safe_name}</p>
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Email</p>
<p style="margin:0 0 12px;color:#22d3ee;font-size:16px;font-weight:600;">{safe_email}</p>
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Company</p>
<p style="margin:0 0 12px;color:#f8fafc;font-size:16px;font-weight:600;">{safe_company}</p>
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Team Size</p>
<p style="margin:0;color:#f8fafc;font-size:16px;font-weight:600;">{safe_team_size}</p>
</td></tr>
</table>
</td></tr>
<tr><td style="padding:0 40px 16px;">
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Message</p>
<p style="margin:0;color:#8891a0;font-size:14px;line-height:1.6;white-space:pre-wrap;">{safe_message}</p>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
Submitted at {date_str} · Lead ID: {lead.id}
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
resend.Emails.send({
"from": settings.FROM_EMAIL,
"to": [to_email],
"reply_to": lead.email,
"subject": subject,
"html": email_html,
})
logger.info("Sales lead notification sent for %s (lead %s)", lead.email, lead.id)
return True
except Exception:
logger.exception(
"Failed to send sales lead notification for %s (lead %s)",
lead.email,
lead.id,
)
return False
@staticmethod
async def send_notification_email(
to_email: str,

View File

@@ -62,6 +62,10 @@ from .session_fact import SessionFact
from .session_suggested_fix import SessionSuggestedFix
from .draft_template import DraftTemplate
from .account_settings import AccountSettings
from .oauth_identity import OAuthIdentity # noqa: F401
from .plan_billing import PlanBilling # noqa: F401
from .sales_lead import SalesLead # noqa: F401
from .stripe_event import StripeEvent # noqa: F401
__all__ = [
"User",
@@ -138,4 +142,8 @@ __all__ = [
"SessionSuggestedFix",
"DraftTemplate",
"AccountSettings",
"OAuthIdentity",
"PlanBilling",
"SalesLead",
"StripeEvent",
]

View File

@@ -48,6 +48,8 @@ class Account(Base):
branding_logo_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
branding_primary_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True) # hex like #06b6d4
branding_company_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
team_size_bucket: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
primary_psa: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
# SSO / SAML groundwork (Task 11)
sso_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")

View File

@@ -27,6 +27,8 @@ class AccountInvite(Base):
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
email_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Relationships
account: Mapped["Account"] = relationship("Account")
@@ -37,6 +39,10 @@ class AccountInvite(Base):
def is_used(self) -> bool:
return self.accepted_by_id is not None
@property
def is_revoked(self) -> bool:
return self.revoked_at is not None
@property
def is_expired(self) -> bool:
if self.expires_at is None:
@@ -45,4 +51,4 @@ class AccountInvite(Base):
@property
def is_valid(self) -> bool:
return not self.is_used and not self.is_expired
return not self.is_used and not self.is_expired and not self.is_revoked

View File

@@ -0,0 +1,36 @@
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class OAuthIdentity(Base):
__tablename__ = "oauth_identities"
__table_args__ = (
UniqueConstraint("provider", "provider_subject", name="uq_oauth_identities_provider_subject"),
Index("ix_oauth_identities_user_id", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
provider: Mapped[str] = mapped_column(String(20), nullable=False)
provider_subject: Mapped[str] = mapped_column(String(255), nullable=False)
provider_email_at_link: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
user: Mapped["User"] = relationship("User", backref="oauth_identities")

View File

@@ -0,0 +1,31 @@
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class PlanBilling(Base):
__tablename__ = "plan_billing"
plan: Mapped[str] = mapped_column(
String(50), ForeignKey("plan_limits.plan"), primary_key=True
)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
monthly_price_cents: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
annual_price_cents: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
stripe_product_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
stripe_monthly_price_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
stripe_annual_price_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,28 @@
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, DateTime, Text, Index
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class SalesLead(Base):
__tablename__ = "sales_leads"
__table_args__ = (Index("ix_sales_leads_email", "email"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
company: Mapped[str] = mapped_column(String(255), nullable=False)
team_size: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String(50), nullable=False)
posthog_distinct_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="new")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,17 @@
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, Index
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import JSONB
from app.core.database import Base
class StripeEvent(Base):
__tablename__ = "stripe_events"
__table_args__ = (Index("ix_stripe_events_event_type", "event_type"),)
id: Mapped[str] = mapped_column(String(255), primary_key=True) # Stripe event id
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
processed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
payload_excerpt: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)

View File

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

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
@@ -33,7 +33,7 @@ class User(Base):
default=uuid.uuid4
)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer")
is_super_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
@@ -76,6 +76,8 @@ class User(Base):
# Onboarding
onboarding_dismissed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, server_default="false")
role_at_signup: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
onboarding_step_completed: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Branding (solo pros without a team)
logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True)

View File

@@ -42,3 +42,12 @@ class AccountInviteResponse(BaseModel):
used_at: Optional[datetime] = None
model_config = {"from_attributes": True}
class AccountInviteBulkCreate(BaseModel):
invites: list[AccountInviteCreate]
class AccountInviteBulkResponse(BaseModel):
created: list[AccountInviteResponse]
failed: list[dict] # entries shaped {"email": str, "error": str}

View File

@@ -172,6 +172,21 @@ class PlanLimitResponse(BaseModel):
from_attributes = True
class PlanLimitWithBillingResponse(PlanLimitResponse):
"""PlanLimits + plan_billing fields merged. Billing fields are None when no
plan_billing row exists for the plan yet."""
display_name: Optional[str] = None
description: Optional[str] = None
monthly_price_cents: Optional[int] = None
annual_price_cents: Optional[int] = None
stripe_product_id: Optional[str] = None
stripe_monthly_price_id: Optional[str] = None
stripe_annual_price_id: Optional[str] = None
is_public: Optional[bool] = None
is_archived: Optional[bool] = None
sort_order: Optional[int] = None
class PlanLimitUpdate(BaseModel):
plan: str
max_trees: Optional[int] = None
@@ -180,6 +195,19 @@ class PlanLimitUpdate(BaseModel):
custom_branding: bool = False
priority_support: bool = False
export_formats: list = Field(default_factory=lambda: ["markdown", "text"])
# plan_billing fields — all optional, partial-update semantics. If any are
# set in the body, the admin endpoint upserts the plan_billing row in the
# same transaction.
display_name: Optional[str] = None
description: Optional[str] = None
monthly_price_cents: Optional[int] = None
annual_price_cents: Optional[int] = None
stripe_product_id: Optional[str] = None
stripe_monthly_price_id: Optional[str] = None
stripe_annual_price_id: Optional[str] = None
is_public: Optional[bool] = None
is_archived: Optional[bool] = None
sort_order: Optional[int] = None
class AccountOverrideCreate(BaseModel):

View File

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

View File

@@ -0,0 +1,18 @@
"""Pydantic schemas for public runtime configuration."""
from __future__ import annotations
from typing import List
from pydantic import BaseModel
class PublicConfigResponse(BaseModel):
"""Runtime feature flags + OAuth provider list exposed to anonymous clients.
Read once by the frontend at app load to decide whether to render the
self-serve signup flow and which OAuth buttons to show.
"""
self_serve_enabled: bool
oauth_providers: List[str]

View File

@@ -0,0 +1,32 @@
from pydantic import BaseModel
class OAuthCallbackPayload(BaseModel):
code: str
state: str | None = None
# When the OAuth flow originated from /accept-invite, the frontend round-trips
# the invite code + invited email so the backend can link the new user to the
# invited account instead of creating a personal one.
account_invite_code: str | None = None
invited_email: str | None = None
class OAuthCallbackResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
is_new_user: bool
class InviteLookupResponse(BaseModel):
"""Public response surface for GET /accounts/invites/{code}/lookup.
Returns the minimum context needed for the AcceptInvitePage:
account name (so we can title the card), inviter name (for the resend
fallback message), invited email (locked into the form), and role.
"""
account_name: str
inviter_name: str
invited_email: str
role: str

View File

@@ -1,12 +1,55 @@
from pydantic import BaseModel
from typing import Literal, Optional
from pydantic import BaseModel, Field
class OnboardingStatus(BaseModel):
created_flow: bool
ran_session: bool
exported_session: bool
# Kept for backward-compat during deploy; new code paths should not branch on this.
tried_ai_assistant: bool
invited_teammate: bool
connected_psa: bool
is_team_user: bool
dismissed: bool
# New (Phase 2 — Task 41) — drive the unified next-step card + checklist.
email_verified: bool
shop_setup_done: bool
# --- Welcome wizard (Phase 2) ----------------------------------------------
TeamSizeBucket = Literal["1-2", "3-5", "6-10", "11-25", "26+"]
RoleAtSignup = Literal["owner", "lead_tech", "tech", "other"]
PrimaryPsa = Literal["connectwise", "autotask", "halopsa", "none"]
WizardStep = Literal[1, 2, 3]
WizardAction = Literal["complete", "skip"]
class OnboardingStepData(BaseModel):
"""Optional payload carried with `action="complete"` for steps 1 and 2.
Step 1 fields: company_name, team_size_bucket, role_at_signup
Step 2 fields: primary_psa
Step 3 has no data (invitations posted separately).
"""
# Step 1
company_name: Optional[str] = Field(default=None, max_length=255)
team_size_bucket: Optional[TeamSizeBucket] = None
role_at_signup: Optional[RoleAtSignup] = None
# Step 2
primary_psa: Optional[PrimaryPsa] = None
class OnboardingStepRequest(BaseModel):
step: WizardStep
action: WizardAction
data: Optional[OnboardingStepData] = None
class OnboardingStepResponse(BaseModel):
onboarding_step_completed: Optional[int]
onboarding_dismissed: bool

View File

@@ -0,0 +1,27 @@
"""Pydantic schemas for Talk-to-Sales submissions."""
from typing import Literal, Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field
SalesLeadSource = Literal["pricing_page", "register_footer", "landing_page"]
class SalesLeadCreate(BaseModel):
"""Public Talk-to-Sales form submission."""
model_config = ConfigDict(str_strip_whitespace=True)
email: EmailStr
name: str = Field(..., min_length=1, max_length=255)
company: str = Field(..., min_length=1, max_length=255)
team_size: Optional[str] = Field(default=None, max_length=20)
message: Optional[str] = Field(default=None, max_length=5000)
source: SalesLeadSource
posthog_distinct_id: Optional[str] = Field(default=None, max_length=255)
class SalesLeadCreateResponse(BaseModel):
id: UUID
status: Literal["received"] = "received"

View File

@@ -58,6 +58,8 @@ class UserResponse(UserBase):
timezone: str = "UTC"
avatar_url: Optional[str] = None
email_verified_at: Optional[datetime] = None
onboarding_step_completed: Optional[int] = None
onboarding_dismissed: bool = False
class Config:
from_attributes = True

View File

@@ -0,0 +1,356 @@
"""Single billing service module. Stripe is the only impl — no provider
abstraction. Account row is canonical local state; Stripe is canonical
remote state; the webhook handler bridges the two."""
import logging
from datetime import datetime, timezone, timedelta
import stripe
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.account import Account
from app.models.plan_billing import PlanBilling
from app.models.stripe_event import StripeEvent
from app.models.subscription import Subscription
TRIAL_DAYS = 14
logger = logging.getLogger(__name__)
class BillingService:
@staticmethod
async def invalidate_billing_cache(account_ids) -> None:
"""No-op stub for future in-process billing cache invalidation.
Today there is no `app.state.billing_cache` — `BillingService.get_billing_state`
always reads fresh from the DB. Call sites that mutate plan/feature data
invoke this hook so that wiring is in place when an in-process cache is
added later. Until then, this just logs.
TODO: when an in-process billing cache (e.g. `app.state.billing_cache`)
is introduced, evict entries for the given account_ids here.
"""
try:
count = len(list(account_ids))
except TypeError:
count = -1
logger.debug(
"BillingService.invalidate_billing_cache called for %d account(s) "
"(no-op stub — wire to app.state.billing_cache when added)",
count,
)
@staticmethod
async def start_trial(db: AsyncSession, account_id) -> Subscription:
"""Idempotent. Creates a trialing Subscription on Pro for the account if
one doesn't exist; otherwise returns the existing row."""
result = await db.execute(
select(Subscription).where(Subscription.account_id == account_id)
)
existing = result.scalar_one_or_none()
if existing is not None:
return existing
sub = Subscription(
account_id=account_id,
plan="pro",
status="trialing",
current_period_start=datetime.now(timezone.utc),
current_period_end=datetime.now(timezone.utc) + timedelta(days=TRIAL_DAYS),
)
db.add(sub)
await db.commit()
await db.refresh(sub)
return sub
@staticmethod
async def create_checkout_session(
db: AsyncSession,
account: Account,
plan: str,
seats: int,
billing_interval: str,
success_url: str,
cancel_url: str,
) -> str:
"""Create a Stripe Checkout Session for subscription purchase. If the
account currently has a trialing subscription with time remaining, that
trial end is preserved on the new Stripe subscription so the user
isn't charged early."""
if not settings.stripe_enabled:
raise RuntimeError("Stripe not configured")
stripe.api_key = settings.STRIPE_SECRET_KEY
plan_billing = (await db.execute(
select(PlanBilling).where(PlanBilling.plan == plan)
)).scalar_one_or_none()
if plan_billing is None:
raise ValueError(f"Unknown plan: {plan}")
price_id = (
plan_billing.stripe_monthly_price_id if billing_interval == "monthly"
else plan_billing.stripe_annual_price_id
)
if price_id is None:
raise RuntimeError(
f"Plan '{plan}' has no Stripe price for {billing_interval}"
)
if account.stripe_customer_id is None:
customer = stripe.Customer.create(
email=None,
metadata={"account_id": str(account.id)},
)
account.stripe_customer_id = customer.id
await db.commit()
sub = (await db.execute(
select(Subscription).where(Subscription.account_id == account.id)
)).scalar_one_or_none()
subscription_data = {}
if (
sub
and sub.status == "trialing"
and sub.current_period_end
and sub.current_period_end > datetime.now(timezone.utc)
):
subscription_data["trial_end"] = int(sub.current_period_end.timestamp())
session = stripe.checkout.Session.create(
customer=account.stripe_customer_id,
line_items=[{"price": price_id, "quantity": seats}],
mode="subscription",
subscription_data=subscription_data or None,
success_url=success_url,
cancel_url=cancel_url,
allow_promotion_codes=False,
)
return session.url
@staticmethod
async def open_customer_portal(account: Account) -> str:
"""Create a Stripe-hosted Customer Portal session and return the URL.
Raises RuntimeError if Stripe isn't configured (endpoint maps to 503).
Raises ValueError if the account has no stripe_customer_id yet — the
user must complete a checkout first (endpoint maps to 400).
"""
if not settings.stripe_enabled:
raise RuntimeError("Stripe not configured")
if account.stripe_customer_id is None:
raise ValueError("no_stripe_customer")
stripe.api_key = settings.STRIPE_SECRET_KEY
session = stripe.billing_portal.Session.create(
customer=account.stripe_customer_id,
return_url=f"{settings.FRONTEND_URL}/account/billing",
)
return session.url
@staticmethod
async def get_billing_state(db: AsyncSession, account):
"""Aggregate Subscription + PlanLimits + PlanBilling + resolved feature
flags for the account."""
from app.models.plan_limits import PlanLimits
from app.models.plan_billing import PlanBilling
from app.models.feature_flag import (
FeatureFlag, PlanFeatureDefault, AccountFeatureOverride,
)
sub = (await db.execute(
select(Subscription).where(Subscription.account_id == account.id)
)).scalar_one_or_none()
if sub is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="No subscription for account")
pl = (await db.execute(
select(PlanLimits).where(PlanLimits.plan == sub.plan)
)).scalar_one_or_none()
pb = (await db.execute(
select(PlanBilling).where(PlanBilling.plan == sub.plan)
)).scalar_one_or_none()
# Resolved feature flags: plan defaults overridden by account overrides
defaults = (await db.execute(
select(PlanFeatureDefault, FeatureFlag)
.join(FeatureFlag, PlanFeatureDefault.flag_id == FeatureFlag.id)
.where(PlanFeatureDefault.plan == sub.plan)
)).all()
resolved = {flag.flag_key: pfd.enabled for pfd, flag in defaults}
overrides = (await db.execute(
select(AccountFeatureOverride, FeatureFlag)
.join(FeatureFlag, AccountFeatureOverride.flag_id == FeatureFlag.id)
.where(AccountFeatureOverride.account_id == account.id)
)).all()
for ovr, flag in overrides:
resolved[flag.flag_key] = ovr.enabled
return {
"subscription": {
"status": sub.status,
"plan": sub.plan,
"current_period_start": sub.current_period_start,
"current_period_end": sub.current_period_end,
"cancel_at_period_end": sub.cancel_at_period_end,
"seat_limit": sub.seat_limit,
"has_pro_entitlement": sub.has_pro_entitlement,
"is_paid": sub.is_paid,
},
"plan_billing": pb,
"plan_limits": _plan_limits_to_dict(pl) if pl else {},
"enabled_features": resolved,
}
@staticmethod
async def apply_subscription_event(
db: AsyncSession, event_id: str, event_type: str, payload: dict
) -> bool:
"""Idempotent. Returns True if the event was applied; False if it had
already been processed (idempotent ack). The webhook handler returns 200
either way.
Atomic: the StripeEvent idempotency mark and the handler's state
mutations are committed in a single transaction. If the handler raises
the entire transaction (idempotency mark + partial mutations) is rolled
back, so a Stripe retry will re-run the handler. Without this, a
handler that fails mid-flight would leave the StripeEvent row persisted
and silently desync subscription state from Stripe.
"""
db.add(StripeEvent(
id=event_id,
event_type=event_type,
payload_excerpt=_excerpt(payload),
))
try:
await db.flush()
except IntegrityError:
# Duplicate event_id — already processed (or in flight). Ack with False.
await db.rollback()
return False
try:
if event_type == "checkout.session.completed":
await _handle_checkout_completed(db, payload)
elif event_type == "customer.subscription.updated":
await _handle_subscription_updated(db, payload)
elif event_type == "customer.subscription.deleted":
await _handle_subscription_deleted(db, payload)
elif event_type == "invoice.payment_failed":
await _handle_payment_failed(db, payload)
elif event_type == "invoice.payment_succeeded":
await _handle_payment_succeeded(db, payload)
await db.commit()
except Exception:
# Roll back the StripeEvent insert + any partial handler mutations
# so Stripe's retry can re-run cleanly.
await db.rollback()
raise
return True
def _plan_limits_to_dict(pl) -> dict:
return {c.name: getattr(pl, c.name) for c in pl.__table__.columns}
def _excerpt(payload: dict) -> dict:
obj = payload.get("data", {}).get("object", {})
return {
"object_id": obj.get("id"),
"customer": obj.get("customer"),
"subscription": obj.get("subscription"),
"status": obj.get("status"),
}
async def _handle_checkout_completed(db: AsyncSession, payload: dict):
obj = payload["data"]["object"]
customer_id = obj["customer"]
subscription_id = obj["subscription"]
account = (await db.execute(
select(Account).where(Account.stripe_customer_id == customer_id)
)).scalar_one_or_none()
if account is None:
return
sub = (await db.execute(
select(Subscription).where(Subscription.account_id == account.id)
)).scalar_one_or_none()
if sub is None:
return
stripe.api_key = settings.STRIPE_SECRET_KEY
stripe_sub = stripe.Subscription.retrieve(subscription_id)
sub.stripe_subscription_id = subscription_id
sub.stripe_price_id = stripe_sub["items"]["data"][0]["price"]["id"]
sub.status = "active"
sub.current_period_start = datetime.fromtimestamp(stripe_sub["current_period_start"], tz=timezone.utc)
sub.current_period_end = datetime.fromtimestamp(stripe_sub["current_period_end"], tz=timezone.utc)
sub.seat_limit = stripe_sub["items"]["data"][0]["quantity"]
pb = (await db.execute(
select(PlanBilling).where(
(PlanBilling.stripe_monthly_price_id == sub.stripe_price_id) |
(PlanBilling.stripe_annual_price_id == sub.stripe_price_id)
)
)).scalar_one_or_none()
if pb is not None:
sub.plan = pb.plan
# No commit — apply_subscription_event commits once for the full event.
async def _handle_subscription_updated(db: AsyncSession, payload: dict):
obj = payload["data"]["object"]
sub = (await db.execute(
select(Subscription).where(Subscription.stripe_subscription_id == obj["id"])
)).scalar_one_or_none()
if sub is None:
return
sub.status = obj["status"]
sub.current_period_start = datetime.fromtimestamp(obj["current_period_start"], tz=timezone.utc)
sub.current_period_end = datetime.fromtimestamp(obj["current_period_end"], tz=timezone.utc)
sub.cancel_at_period_end = obj.get("cancel_at_period_end", False)
sub.seat_limit = obj["items"]["data"][0]["quantity"]
# No commit — apply_subscription_event commits once for the full event.
async def _handle_subscription_deleted(db: AsyncSession, payload: dict):
obj = payload["data"]["object"]
sub = (await db.execute(
select(Subscription).where(Subscription.stripe_subscription_id == obj["id"])
)).scalar_one_or_none()
if sub is None:
return
sub.status = "canceled"
# No commit — apply_subscription_event commits once for the full event.
async def _handle_payment_failed(db: AsyncSession, payload: dict):
obj = payload["data"]["object"]
subscription_id = obj.get("subscription")
if not subscription_id:
return
sub = (await db.execute(
select(Subscription).where(Subscription.stripe_subscription_id == subscription_id)
)).scalar_one_or_none()
if sub is None:
return
sub.status = "past_due"
# No commit — apply_subscription_event commits once for the full event.
async def _handle_payment_succeeded(db: AsyncSession, payload: dict):
obj = payload["data"]["object"]
subscription_id = obj.get("subscription")
if not subscription_id:
return
sub = (await db.execute(
select(Subscription).where(Subscription.stripe_subscription_id == subscription_id)
)).scalar_one_or_none()
if sub is None:
return
if sub.status == "past_due":
sub.status = "active"
# No commit — apply_subscription_event commits once for the full event.

View File

@@ -0,0 +1,71 @@
"""OAuth provider helpers. Each provider exposes:
- exchange_code(code, redirect_uri) -> OAuthProfile
"""
from dataclasses import dataclass
import httpx
from app.core.config import settings
@dataclass
class OAuthProfile:
provider_subject: str
email: str
name: str
async def google_exchange_code(code: str, redirect_uri: str) -> OAuthProfile:
async with httpx.AsyncClient(timeout=10) as cli:
token_response = await cli.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": settings.GOOGLE_CLIENT_ID,
"client_secret": settings.GOOGLE_CLIENT_SECRET,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
token_response.raise_for_status()
access_token = token_response.json()["access_token"]
userinfo = await cli.get(
"https://openidconnect.googleapis.com/v1/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
userinfo.raise_for_status()
data = userinfo.json()
return OAuthProfile(
provider_subject=data["sub"],
email=data["email"],
name=data.get("name") or data["email"].split("@")[0],
)
async def microsoft_exchange_code(code: str, redirect_uri: str) -> OAuthProfile:
async with httpx.AsyncClient(timeout=10) as cli:
token_response = await cli.post(
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
data={
"code": code,
"client_id": settings.MS_CLIENT_ID,
"client_secret": settings.MS_CLIENT_SECRET,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
"scope": "openid email profile",
},
)
token_response.raise_for_status()
access_token = token_response.json()["access_token"]
userinfo = await cli.get(
"https://graph.microsoft.com/v1.0/me",
headers={"Authorization": f"Bearer {access_token}"},
)
userinfo.raise_for_status()
data = userinfo.json()
return OAuthProfile(
provider_subject=data["id"],
email=data.get("mail") or data["userPrincipalName"],
name=data.get("displayName") or data["userPrincipalName"].split("@")[0],
)

View File

@@ -248,13 +248,23 @@ async def client(test_db: AsyncSession):
@pytest.fixture
async def test_user(client):
async def test_user(client, test_db):
"""
Create a test user and return their credentials.
Also seeds a default active Pro Subscription so Pro-guarded routes work
in tests. Phase 1 Task 11 added require_active_subscription; without
this seed every existing test that hits a Pro router would 402. The
register endpoint creates a default `free`/`active` Subscription, so
we delete-then-insert to avoid the unique account_id constraint.
Returns:
dict with email, password, and user_data
"""
import uuid
from sqlalchemy import delete
from app.models.subscription import Subscription
user_data = {
"email": "test@example.com",
"password": "TestPassword123!",
@@ -264,6 +274,13 @@ async def test_user(client):
response = await client.post("/api/v1/auth/register", json=user_data)
assert response.status_code == 200 or response.status_code == 201
account_id = uuid.UUID(response.json()["account_id"])
await test_db.execute(
delete(Subscription).where(Subscription.account_id == account_id)
)
test_db.add(Subscription(account_id=account_id, plan="pro", status="active"))
await test_db.commit()
return {
"email": user_data["email"],
"password": user_data["password"],
@@ -346,11 +363,14 @@ async def test_admin(client, test_db):
Create a test super-admin user.
Registers as engineer (the only role available at registration),
then promotes to super_admin directly via the DB session.
then promotes to super_admin directly via the DB session. Also
seeds a default active Pro Subscription (see test_user docstring).
"""
import uuid
from uuid import UUID as PyUUID
from sqlalchemy import select
from sqlalchemy import select, delete
from app.models.user import User
from app.models.subscription import Subscription
admin_data = {
"email": "admin@example.com",
@@ -365,6 +385,12 @@ async def test_admin(client, test_db):
result = await test_db.execute(select(User).where(User.id == user_id))
user = result.scalar_one()
user.is_super_admin = True
account_id = uuid.UUID(response.json()["account_id"])
await test_db.execute(
delete(Subscription).where(Subscription.account_id == account_id)
)
test_db.add(Subscription(account_id=account_id, plan="pro", status="active"))
await test_db.commit()
return {

View File

@@ -0,0 +1,180 @@
import pytest
from unittest.mock import AsyncMock, patch
from sqlalchemy import select
from app.models.account_invite import AccountInvite
@pytest.mark.asyncio
async def test_create_invite_sends_email_and_stamps_email_sent_at(
client, test_db, test_user, auth_headers
):
"""Regression: today's create_invite does NOT send email. After this task, it MUST."""
with patch(
"app.core.email.EmailService.send_account_invite_email",
new_callable=AsyncMock, return_value=True,
) as mock_send:
response = await client.post(
"/api/v1/accounts/me/invites",
json={"email": "teammate@example.com", "role": "engineer"},
headers=auth_headers,
)
assert response.status_code == 201, response.json()
mock_send.assert_called_once()
kwargs = mock_send.call_args.kwargs
assert kwargs["to_email"] == "teammate@example.com"
assert kwargs["role"] == "engineer"
assert kwargs["code"]
invite = (await test_db.execute(
select(AccountInvite).where(AccountInvite.email == "teammate@example.com")
)).scalar_one()
assert invite.email_sent_at is not None
@pytest.mark.asyncio
async def test_create_invite_email_failure_still_creates_row(
client, test_db, test_user, auth_headers
):
"""When EmailService returns False, the invite row is still created but
email_sent_at remains NULL."""
with patch(
"app.core.email.EmailService.send_account_invite_email",
new_callable=AsyncMock, return_value=False,
):
response = await client.post(
"/api/v1/accounts/me/invites",
json={"email": "fail-mail@example.com", "role": "engineer"},
headers=auth_headers,
)
assert response.status_code == 201
invite = (await test_db.execute(
select(AccountInvite).where(AccountInvite.email == "fail-mail@example.com")
)).scalar_one()
assert invite.email_sent_at is None
@pytest.mark.asyncio
async def test_bulk_invite_creates_n_rows_and_sends_n_emails(
client, test_db, test_user, auth_headers
):
with patch(
"app.core.email.EmailService.send_account_invite_email",
new_callable=AsyncMock, return_value=True,
) as mock_send:
response = await client.post(
"/api/v1/accounts/me/invites/bulk",
json={"invites": [
{"email": "a@example.com", "role": "engineer"},
{"email": "b@example.com", "role": "engineer"},
{"email": "c@example.com", "role": "viewer"},
]},
headers=auth_headers,
)
assert response.status_code == 201, response.json()
body = response.json()
assert len(body["created"]) == 3
assert body["failed"] == []
assert mock_send.call_count == 3
@pytest.mark.asyncio
async def test_revoke_invite_sets_revoked_at(client, test_db, test_user, auth_headers):
import uuid
from datetime import datetime, timezone, timedelta
from app.models.account_invite import AccountInvite
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
account_id = uuid.UUID(test_user["user_data"]["account_id"])
invite = AccountInvite(
account_id=account_id,
invited_by_id=invited_by_id,
email="revoked@example.com",
code="REVOKEME01",
role="engineer",
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
)
test_db.add(invite)
await test_db.commit()
invite_id = invite.id
response = await client.delete(
f"/api/v1/accounts/me/invites/{invite_id}",
headers=auth_headers,
)
assert response.status_code == 204
await test_db.refresh(invite)
assert invite.revoked_at is not None
assert invite.is_valid is False
@pytest.mark.asyncio
async def test_revoke_invite_idempotent(client, test_db, test_user, auth_headers):
import uuid
from datetime import datetime, timezone, timedelta
from app.models.account_invite import AccountInvite
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
account_id = uuid.UUID(test_user["user_data"]["account_id"])
invite = AccountInvite(
account_id=account_id,
invited_by_id=invited_by_id,
email="revoked2@example.com",
code="REVOKEME02",
role="engineer",
revoked_at=datetime.now(timezone.utc),
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
)
test_db.add(invite)
await test_db.commit()
invite_id = invite.id
response = await client.delete(
f"/api/v1/accounts/me/invites/{invite_id}",
headers=auth_headers,
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_revoke_invite_404_when_not_found(client, test_user, auth_headers):
import uuid
response = await client.delete(
f"/api/v1/accounts/me/invites/{uuid.uuid4()}",
headers=auth_headers,
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_revoke_used_invite_returns_400(
client, test_db, test_user, auth_headers
):
import uuid
from datetime import datetime, timezone, timedelta
from app.models.account_invite import AccountInvite
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
account_id = uuid.UUID(test_user["user_data"]["account_id"])
invite = AccountInvite(
account_id=account_id,
invited_by_id=invited_by_id,
email="used@example.com",
code="USEDCODE01",
role="engineer",
accepted_by_id=invited_by_id, # mark as used
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
)
test_db.add(invite)
await test_db.commit()
invite_id = invite.id
response = await client.delete(
f"/api/v1/accounts/me/invites/{invite_id}",
headers=auth_headers,
)
assert response.status_code == 400

View File

@@ -0,0 +1,290 @@
"""Tests for the public GET /accounts/invites/{code}/lookup endpoint
(consumed by the /accept-invite page on the frontend)."""
import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy import select
from app.models.account_invite import AccountInvite
@pytest.mark.asyncio
async def test_invite_lookup_returns_account_info_for_valid_code(
client, test_db, test_user, auth_headers
):
"""A freshly-created, unused, unexpired invite resolves to the inviter's
account name + the inviter's display name + the invited email + role."""
with patch(
"app.core.email.EmailService.send_account_invite_email",
new_callable=AsyncMock,
return_value=True,
):
create_resp = await client.post(
"/api/v1/accounts/me/invites",
json={"email": "lookup@example.com", "role": "engineer"},
headers=auth_headers,
)
assert create_resp.status_code == 201, create_resp.json()
code = create_resp.json()["code"]
response = await client.get(f"/api/v1/accounts/invites/{code}/lookup")
assert response.status_code == 200, response.json()
body = response.json()
assert body["invited_email"] == "lookup@example.com"
assert body["role"] == "engineer"
assert body["inviter_name"] == test_user["user_data"]["name"]
# account_name is whatever the test_user fixture seeded for the account.
assert isinstance(body["account_name"], str) and body["account_name"]
@pytest.mark.asyncio
async def test_invite_lookup_returns_404_for_invalid_or_expired_code(
client, test_db, test_user
):
"""Three failure modes (unknown code, expired, revoked, used) all collapse
to the same 404 + invite_invalid_or_expired_or_revoked error code."""
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
account_id = uuid.UUID(test_user["user_data"]["account_id"])
# 1) Unknown code
unknown = await client.get("/api/v1/accounts/invites/DOESNOTEXIST/lookup")
assert unknown.status_code == 404
assert unknown.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked"
# 2) Expired
expired_invite = AccountInvite(
account_id=account_id,
invited_by_id=invited_by_id,
email="expired@example.com",
code="EXPIREDLOOKUP01",
role="engineer",
expires_at=datetime.now(timezone.utc) - timedelta(days=1),
)
test_db.add(expired_invite)
await test_db.commit()
expired = await client.get("/api/v1/accounts/invites/EXPIREDLOOKUP01/lookup")
assert expired.status_code == 404
assert expired.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked"
# 3) Revoked
revoked_invite = AccountInvite(
account_id=account_id,
invited_by_id=invited_by_id,
email="revoked@example.com",
code="REVOKEDLOOKUP01",
role="engineer",
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
revoked_at=datetime.now(timezone.utc),
)
test_db.add(revoked_invite)
await test_db.commit()
revoked = await client.get("/api/v1/accounts/invites/REVOKEDLOOKUP01/lookup")
assert revoked.status_code == 404
assert revoked.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked"
# 4) Already used
used_invite = AccountInvite(
account_id=account_id,
invited_by_id=invited_by_id,
email="used@example.com",
code="USEDLOOKUP01",
role="engineer",
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
accepted_by_id=invited_by_id,
used_at=datetime.now(timezone.utc),
)
test_db.add(used_invite)
await test_db.commit()
used = await client.get("/api/v1/accounts/invites/USEDLOOKUP01/lookup")
assert used.status_code == 404
assert used.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked"
# Sanity: rows survived (no destructive side effects).
persisted = (
await test_db.execute(
select(AccountInvite).where(
AccountInvite.code.in_(
["EXPIREDLOOKUP01", "REVOKEDLOOKUP01", "USEDLOOKUP01"]
)
)
)
).scalars().all()
assert len(persisted) == 3
@pytest.mark.asyncio
async def test_oauth_callback_links_invite_when_account_invite_code_supplied(
client, test_db, test_user, auth_headers, monkeypatch
):
"""Brand-new OAuth user with account_invite_code joins the invited account
instead of getting a personal one. Invite is marked used."""
from app.core.config import settings
from app.models.user import User
from app.services.oauth_providers import OAuthProfile
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
with patch(
"app.core.email.EmailService.send_account_invite_email",
new_callable=AsyncMock,
return_value=True,
):
create_resp = await client.post(
"/api/v1/accounts/me/invites",
json={"email": "oauth-invite@example.com", "role": "engineer"},
headers=auth_headers,
)
code = create_resp.json()["code"]
inviter_account_id = uuid.UUID(test_user["user_data"]["account_id"])
profile = OAuthProfile(
provider_subject="google_invite_subject_1",
email="oauth-invite@example.com",
name="OAuth Invitee",
)
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
response = await client.post(
"/api/v1/auth/google/callback",
json={
"code": "auth_code_xyz",
"account_invite_code": code,
"invited_email": "oauth-invite@example.com",
},
)
assert response.status_code == 200, response.json()
assert response.json()["is_new_user"] is True
user = (
await test_db.execute(
select(User).where(User.email == "oauth-invite@example.com")
)
).scalar_one()
assert user.account_id == inviter_account_id
assert user.account_role == "engineer"
invite = (
await test_db.execute(
select(AccountInvite).where(AccountInvite.code == code)
)
).scalar_one()
assert invite.used_at is not None
assert invite.accepted_by_id == user.id
@pytest.mark.asyncio
async def test_oauth_callback_existing_email_with_invite_returns_400(
client, test_db, test_user, auth_headers, monkeypatch
):
"""If a user already exists with the invited email (e.g., previously
registered via password), arriving via /accept-invite OAuth must NOT
silently link the OAuth identity to their existing account and skip the
invite. Surface email_already_registered_use_login so the user signs in
and accepts the invite from the dashboard instead."""
from app.core.config import settings
from app.services.oauth_providers import OAuthProfile
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
# 1) Pre-existing user with a password (separate from the inviter).
existing_email = "already-here@example.com"
register_resp = await client.post(
"/api/v1/auth/register",
json={
"email": existing_email,
"password": "PreviousPassword123!",
"name": "Already Here",
},
)
assert register_resp.status_code in (200, 201), register_resp.json()
# 2) Inviter creates an invite for that exact email.
with patch(
"app.core.email.EmailService.send_account_invite_email",
new_callable=AsyncMock,
return_value=True,
):
create_resp = await client.post(
"/api/v1/accounts/me/invites",
json={"email": existing_email, "role": "engineer"},
headers=auth_headers,
)
assert create_resp.status_code == 201, create_resp.json()
code = create_resp.json()["code"]
# 3) The existing user does Google OAuth and the callback receives the
# invite code. Backend must reject — not link silently.
profile = OAuthProfile(
provider_subject="google_existing_subject_1",
email=existing_email,
name="Already Here",
)
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
response = await client.post(
"/api/v1/auth/google/callback",
json={
"code": "auth_code_xyz",
"account_invite_code": code,
"invited_email": existing_email,
},
)
assert response.status_code == 400, response.json()
assert (
response.json()["detail"]["error"] == "email_already_registered_use_login"
)
# 4) Sanity: the invite was NOT consumed.
invite = (
await test_db.execute(
select(AccountInvite).where(AccountInvite.code == code)
)
).scalar_one()
assert invite.used_at is None
assert invite.accepted_by_id is None
@pytest.mark.asyncio
async def test_oauth_callback_invite_email_mismatch_returns_400(
client, test_db, test_user, auth_headers, monkeypatch
):
"""If the OAuth profile's email differs from the invite's email, the
backend rejects the link with invite_email_mismatch (mirrors register)."""
from app.core.config import settings
from app.services.oauth_providers import OAuthProfile
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
with patch(
"app.core.email.EmailService.send_account_invite_email",
new_callable=AsyncMock,
return_value=True,
):
create_resp = await client.post(
"/api/v1/accounts/me/invites",
json={"email": "expected@example.com", "role": "engineer"},
headers=auth_headers,
)
code = create_resp.json()["code"]
profile = OAuthProfile(
provider_subject="google_invite_subject_2",
email="different@example.com",
name="Wrong Email",
)
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
response = await client.post(
"/api/v1/auth/google/callback",
json={
"code": "auth_code_xyz",
"account_invite_code": code,
"invited_email": "expected@example.com",
},
)
assert response.status_code == 400, response.json()
assert response.json()["detail"]["error"] == "invite_email_mismatch"

View File

@@ -0,0 +1,27 @@
import pytest
from datetime import datetime, timezone, timedelta
from app.models.account_invite import AccountInvite
def make_invite(**kwargs):
return AccountInvite(
account_id=kwargs.get("account_id", "00000000-0000-0000-0000-000000000001"),
invited_by_id=kwargs.get("invited_by_id", "00000000-0000-0000-0000-000000000002"),
email=kwargs.get("email", "x@y.com"),
code=kwargs.get("code", "ABCD1234"),
role=kwargs.get("role", "engineer"),
accepted_by_id=kwargs.get("accepted_by_id"),
expires_at=kwargs.get("expires_at"),
revoked_at=kwargs.get("revoked_at"),
)
def test_invite_revoked_is_invalid():
invite = make_invite(revoked_at=datetime.now(timezone.utc))
assert invite.is_revoked is True
assert invite.is_valid is False
def test_invite_unrevoked_unexpired_unused_is_valid():
invite = make_invite(expires_at=datetime.now(timezone.utc) + timedelta(days=7))
assert invite.is_valid is True

View File

@@ -21,17 +21,21 @@ class TestAccountEndpoints:
@pytest.mark.asyncio
async def test_get_my_subscription(self, client: AsyncClient, auth_headers: dict):
"""Test getting current user's subscription details."""
"""Test getting current user's subscription details.
The test_user fixture seeds a Pro/active Subscription so
Pro-guarded routers work; reflect that in the expected plan.
"""
response = await client.get("/api/v1/accounts/me/subscription", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "subscription" in data
assert "limits" in data
assert "usage" in data
assert data["subscription"]["plan"] == "free"
assert data["subscription"]["plan"] == "pro"
assert data["subscription"]["status"] == "active"
assert data["limits"]["max_trees"] == 3
assert data["limits"]["max_sessions_per_month"] == 20
assert data["limits"]["max_trees"] == 25
assert data["limits"]["max_sessions_per_month"] == 200
@pytest.mark.asyncio
async def test_get_my_members(self, client: AsyncClient, auth_headers: dict):

View File

@@ -1,7 +1,12 @@
"""Integration tests for admin plan limits and account override endpoints."""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.models.plan_billing import PlanBilling
class TestAdminPlanLimits:
@@ -56,3 +61,204 @@ class TestAdminPlanLimits:
"""Non-admin gets 403."""
response = await client.get("/api/v1/admin/plan-limits", headers=auth_headers)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_admin_plan_limits_get_includes_plan_billing_fields_when_present(
self, client: AsyncClient, admin_auth_headers: dict, test_db
):
"""GET /admin/plan-limits returns plan_billing fields when a row exists,
and None for plans that don't have one yet."""
# Seed a plan_billing row for "pro".
existing = (await test_db.execute(
select(PlanBilling).where(PlanBilling.plan == "pro")
)).scalar_one_or_none()
if existing is None:
test_db.add(PlanBilling(
plan="pro",
display_name="Pro",
description="For working teams",
monthly_price_cents=4900,
annual_price_cents=49000,
stripe_product_id="prod_seed",
stripe_monthly_price_id="price_seed_m",
stripe_annual_price_id="price_seed_a",
is_public=True,
is_archived=False,
sort_order=10,
))
await test_db.commit()
response = await client.get(
"/api/v1/admin/plan-limits", headers=admin_auth_headers
)
assert response.status_code == 200
plans_by_name = {p["plan"]: p for p in response.json()}
assert "pro" in plans_by_name
pro = plans_by_name["pro"]
assert pro["display_name"] == "Pro"
assert pro["monthly_price_cents"] == 4900
assert pro["stripe_monthly_price_id"] == "price_seed_m"
assert pro["is_public"] is True
assert pro["is_archived"] is False
assert pro["sort_order"] == 10
# A plan without a plan_billing row should still return, with None
# billing fields.
if "free" in plans_by_name:
free = plans_by_name["free"]
# free has no plan_billing row in the seed → fields are None.
no_billing_row = (await test_db.execute(
select(PlanBilling).where(PlanBilling.plan == "free")
)).scalar_one_or_none() is None
if no_billing_row:
assert free["display_name"] is None
assert free["monthly_price_cents"] is None
assert free["stripe_product_id"] is None
@pytest.mark.asyncio
async def test_admin_plan_limits_put_creates_plan_billing_row(
self, client: AsyncClient, admin_auth_headers: dict, test_db
):
"""PUT /admin/plan-limits upserts a plan_billing row when billing
fields are included in the body."""
# Ensure no plan_billing row exists for "team" yet.
existing = (await test_db.execute(
select(PlanBilling).where(PlanBilling.plan == "team")
)).scalar_one_or_none()
if existing is not None:
await test_db.delete(existing)
await test_db.commit()
response = await client.put(
"/api/v1/admin/plan-limits",
json={
"plan": "team",
"max_trees": None,
"max_sessions_per_month": None,
"max_users": None,
"custom_branding": True,
"priority_support": True,
"export_formats": ["markdown", "text", "pdf"],
"display_name": "Team",
"description": "For growing shops",
"monthly_price_cents": 9900,
"annual_price_cents": 99000,
"stripe_product_id": "prod_team_test",
"stripe_monthly_price_id": "price_team_m",
"stripe_annual_price_id": "price_team_a",
"is_public": True,
"is_archived": False,
"sort_order": 20,
},
headers=admin_auth_headers,
)
assert response.status_code == 200, response.text
body = response.json()
assert body["display_name"] == "Team"
assert body["monthly_price_cents"] == 9900
assert body["stripe_product_id"] == "prod_team_test"
assert body["sort_order"] == 20
# Confirm the row was actually persisted.
await test_db.commit() # ensure session sees other-session writes
pb = (await test_db.execute(
select(PlanBilling).where(PlanBilling.plan == "team")
)).scalar_one_or_none()
assert pb is not None
assert pb.display_name == "Team"
assert pb.monthly_price_cents == 9900
assert pb.stripe_monthly_price_id == "price_team_m"
assert pb.is_public is True
@pytest.mark.asyncio
async def test_admin_plan_limits_put_does_not_null_out_required_fields(
self, client: AsyncClient, admin_auth_headers: dict, test_db
):
"""PUT /admin/plan-limits must not NULL out NOT NULL columns on the
plan_billing row when the caller passes explicit nulls. The set of
guarded fields is {display_name, is_public, is_archived, sort_order}.
"""
# Seed a plan_billing row for "team" with non-default values for every
# NOT NULL field so we can detect any clobbering.
existing = (await test_db.execute(
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="team",
display_name="Team Seeded",
is_public=False,
is_archived=True,
sort_order=5,
)
test_db.add(seeded)
await test_db.commit()
response = await client.put(
"/api/v1/admin/plan-limits",
json={
"plan": "team",
"max_trees": None,
"max_sessions_per_month": None,
"max_users": None,
"custom_branding": True,
"priority_support": True,
"export_formats": ["markdown", "text"],
# Explicit nulls for every NOT NULL plan_billing field.
"display_name": None,
"is_public": None,
"is_archived": None,
"sort_order": None,
},
headers=admin_auth_headers,
)
assert response.status_code == 200, response.text
# Confirm the seeded NOT NULL values were preserved.
await test_db.commit() # ensure session sees writes from the request
pb = (await test_db.execute(
select(PlanBilling).where(PlanBilling.plan == "team")
)).scalar_one_or_none()
assert pb is not None
assert pb.display_name == "Team Seeded"
assert pb.is_public is False
assert pb.is_archived is True
assert pb.sort_order == 5
@pytest.mark.asyncio
async def test_admin_plan_limits_put_invalidates_billing_cache(
self, client: AsyncClient, admin_auth_headers: dict
):
"""PUT /admin/plan-limits calls BillingService.invalidate_billing_cache
with the account_ids on the affected plan."""
# Patch the staticmethod on the class. The endpoint imports
# BillingService at module load, so patch the symbol on the class
# itself — both the import and the dotted reference resolve to it.
with patch(
"app.api.endpoints.admin_plan_limits.BillingService.invalidate_billing_cache",
new_callable=AsyncMock,
) as spy:
response = await client.put(
"/api/v1/admin/plan-limits",
json={
"plan": "pro",
"max_trees": 25,
"max_sessions_per_month": 500,
"max_users": 10,
"custom_branding": True,
"priority_support": True,
"export_formats": ["markdown", "text"],
},
headers=admin_auth_headers,
)
assert response.status_code == 200, response.text
spy.assert_awaited_once()
(account_ids_arg,) = spy.await_args.args
# admin fixture seeds an active Pro Subscription, so we expect at
# least one account_id in the invalidation list.
assert isinstance(account_ids_arg, list)
assert len(account_ids_arg) >= 1

View File

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

View File

@@ -0,0 +1,56 @@
import pytest
from unittest.mock import patch, MagicMock
from app.models.plan_billing import PlanBilling
@pytest.mark.asyncio
async def test_checkout_session_creates_stripe_session(
client, test_db, test_user, auth_headers, monkeypatch
):
"""End-to-end: post body → Stripe SDK called → URL returned. Stripe SDK
mocked; Customer + Session calls patched."""
from app.core.config import settings
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
test_db.add(PlanBilling(
plan="pro",
display_name="Pro",
stripe_product_id="prod_test",
stripe_monthly_price_id="price_test_monthly",
))
await test_db.commit()
fake_customer = MagicMock()
fake_customer.id = "cus_test_123"
fake_session = MagicMock()
fake_session.url = "https://checkout.stripe.com/test"
with patch("stripe.Customer.create", return_value=fake_customer) as cust_mock, \
patch("stripe.checkout.Session.create", return_value=fake_session) as sess_mock:
response = await client.post(
"/api/v1/billing/checkout-session",
json={"plan": "pro", "seats": 3, "billing_interval": "monthly"},
headers=auth_headers,
)
assert response.status_code == 200, response.json()
assert response.json()["url"] == "https://checkout.stripe.com/test"
cust_mock.assert_called_once()
sess_mock.assert_called_once()
@pytest.mark.asyncio
async def test_checkout_session_unknown_plan_returns_500(
client, test_db, test_user, auth_headers, monkeypatch
):
"""No PlanBilling row → ValueError surfaces as 500 (the endpoint doesn't
catch business errors)."""
from app.core.config import settings
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
response = await client.post(
"/api/v1/billing/checkout-session",
json={"plan": "pro", "seats": 1, "billing_interval": "monthly"},
headers=auth_headers,
)
assert response.status_code == 500

View File

@@ -0,0 +1,83 @@
import uuid
import pytest
from unittest.mock import patch, MagicMock
from sqlalchemy import select
from app.models.account import Account
@pytest.mark.asyncio
async def test_billing_portal_returns_url_for_account_with_stripe_customer(
client, test_db, test_user, auth_headers, monkeypatch
):
"""Happy path: account has a stripe_customer_id and Stripe is configured →
GET /billing/portal-session returns the portal URL."""
from app.core.config import settings
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
monkeypatch.setattr(settings, "FRONTEND_URL", "https://app.example.com")
account_id = uuid.UUID(test_user["user_data"]["account_id"])
account = (await test_db.execute(
select(Account).where(Account.id == account_id)
)).scalar_one()
account.stripe_customer_id = "cus_test_456"
await test_db.commit()
fake_session = MagicMock()
fake_session.url = "https://billing.stripe.com/p/session/test_abc"
with patch(
"stripe.billing_portal.Session.create",
return_value=fake_session,
) as portal_mock:
response = await client.get(
"/api/v1/billing/portal-session",
headers=auth_headers,
)
assert response.status_code == 200, response.json()
assert response.json() == {"url": "https://billing.stripe.com/p/session/test_abc"}
portal_mock.assert_called_once()
call_kwargs = portal_mock.call_args.kwargs
assert call_kwargs["customer"] == "cus_test_456"
assert call_kwargs["return_url"] == "https://app.example.com/account/billing"
@pytest.mark.asyncio
async def test_billing_portal_returns_503_when_stripe_not_configured(
client, test_db, test_user, auth_headers, monkeypatch
):
"""STRIPE_SECRET_KEY unset → settings.stripe_enabled is False → 503."""
from app.core.config import settings
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", None)
response = await client.get(
"/api/v1/billing/portal-session",
headers=auth_headers,
)
assert response.status_code == 503
assert response.json()["detail"]["error"] == "stripe_not_configured"
@pytest.mark.asyncio
async def test_billing_portal_returns_400_when_account_has_no_stripe_customer(
client, test_db, test_user, auth_headers, monkeypatch
):
"""Account with no stripe_customer_id (never completed checkout) → 400
with `no_stripe_customer` error."""
from app.core.config import settings
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
# test_user fixture seeds an account with no stripe_customer_id by default.
account_id = uuid.UUID(test_user["user_data"]["account_id"])
account = (await test_db.execute(
select(Account).where(Account.id == account_id)
)).scalar_one()
assert account.stripe_customer_id is None
response = await client.get(
"/api/v1/billing/portal-session",
headers=auth_headers,
)
assert response.status_code == 400
assert response.json()["detail"]["error"] == "no_stripe_customer"

View File

@@ -0,0 +1,80 @@
import uuid
import pytest
from datetime import datetime, timezone
from sqlalchemy import select, delete
from app.models.subscription import Subscription
from app.services.billing import BillingService
@pytest.mark.asyncio
async def test_start_trial_creates_trialing_pro_subscription(test_db):
"""Direct service test — bypasses register, creates account inline."""
from app.models.account import Account
account = Account(name="DirectTest", display_code="DIRECT01")
test_db.add(account)
await test_db.flush()
sub = await BillingService.start_trial(test_db, account.id)
assert sub.plan == "pro"
assert sub.status == "trialing"
assert sub.current_period_end is not None
assert sub.current_period_end > datetime.now(timezone.utc)
@pytest.mark.asyncio
async def test_start_trial_is_idempotent(test_db):
from app.models.account import Account
account = Account(name="Idempo", display_code="IDEMPO01")
test_db.add(account)
await test_db.flush()
sub1 = await BillingService.start_trial(test_db, account.id)
sub2 = await BillingService.start_trial(test_db, account.id)
assert sub1.id == sub2.id
rows = (await test_db.execute(
select(Subscription).where(Subscription.account_id == account.id)
)).scalars().all()
assert len(rows) == 1
@pytest.mark.asyncio
async def test_register_creates_trial_subscription(client, test_db):
"""Registering a brand-new shop (no invite code) yields a Pro/trialing sub."""
response = await client.post("/api/v1/auth/register", json={
"email": "newshop@example.com",
"password": "Verystrong1Pwd",
"name": "New Shop",
})
assert response.status_code in (200, 201), response.json()
body = response.json()
account_id = uuid.UUID(body["account_id"])
sub = (await test_db.execute(
select(Subscription).where(Subscription.account_id == account_id)
)).scalar_one()
assert sub.plan == "pro"
assert sub.status == "trialing"
assert sub.current_period_end is not None
@pytest.mark.asyncio
async def test_apply_subscription_event_is_idempotent(test_db):
payload = {
"data": {"object": {
"id": "evt_test_1",
"customer": "cus_xxx",
"subscription": "sub_xxx",
"status": "active",
}}
}
applied_first = await BillingService.apply_subscription_event(
test_db, "evt_test_1", "customer.subscription.updated", payload
)
applied_second = await BillingService.apply_subscription_event(
test_db, "evt_test_1", "customer.subscription.updated", payload
)
assert applied_first is True
assert applied_second is False # already-processed → ack without re-applying

View File

@@ -0,0 +1,64 @@
import uuid
import pytest
from sqlalchemy import select
from app.models.subscription import Subscription
from app.models.feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
@pytest.mark.asyncio
async def test_billing_state_returns_subscription_plan_features(
client, test_db, test_user, auth_headers
):
"""Subscription is already seeded by test_user fixture (pro/active).
Add a feature flag default for `pro` and verify it shows up in the response."""
flag = FeatureFlag(flag_key="psa_integration", display_name="PSA Integration")
test_db.add(flag)
await test_db.flush()
test_db.add(PlanFeatureDefault(plan="pro", flag_id=flag.id, enabled=True))
await test_db.commit()
response = await client.get("/api/v1/billing/state", headers=auth_headers)
assert response.status_code == 200, response.json()
body = response.json()
assert body["subscription"]["status"] == "active"
assert body["subscription"]["plan"] == "pro"
assert body["subscription"]["has_pro_entitlement"] is True
assert body["subscription"]["is_paid"] is True
assert body["enabled_features"]["psa_integration"] is True
# plan_limits should be a dict with the seeded pro limits from conftest
assert body["plan_limits"]["plan"] == "pro"
assert body["plan_limits"]["max_trees"] == 25
@pytest.mark.asyncio
async def test_billing_state_account_override_beats_plan_default(
client, test_db, test_user, auth_headers
):
account_id = uuid.UUID(test_user["user_data"]["account_id"])
flag = FeatureFlag(flag_key="escalation_mode", display_name="Escalation Mode")
test_db.add(flag)
await test_db.flush()
test_db.add(PlanFeatureDefault(plan="pro", flag_id=flag.id, enabled=False))
test_db.add(AccountFeatureOverride(
account_id=account_id, flag_id=flag.id, enabled=True,
))
await test_db.commit()
response = await client.get("/api/v1/billing/state", headers=auth_headers)
assert response.status_code == 200
assert response.json()["enabled_features"]["escalation_mode"] is True
@pytest.mark.asyncio
async def test_billing_state_404_when_no_subscription(
client, test_db, test_user, auth_headers
):
"""Wipe the seeded subscription and verify the endpoint surfaces 404."""
from sqlalchemy import delete
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
await test_db.commit()
response = await client.get("/api/v1/billing/state", headers=auth_headers)
assert response.status_code == 404

View File

@@ -0,0 +1,100 @@
"""Integration tests for the public runtime config endpoint.
Covers GET /api/v1/config/public and the SELF_SERVE_ENABLED interaction
with the existing /auth/register invite-code gate.
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from app.core.config import settings
class TestConfigPublic:
"""GET /api/v1/config/public — anonymous, no auth."""
@pytest.mark.asyncio
async def test_get_config_public_returns_self_serve_flag(
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
):
"""Endpoint reflects the current SELF_SERVE_ENABLED setting and the
configured OAuth providers, with no auth required."""
# Default-off: SELF_SERVE_ENABLED is False unless explicitly set.
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
response = await client.get("/api/v1/config/public")
assert response.status_code == 200
body = response.json()
assert body == {"self_serve_enabled": False, "oauth_providers": []}
# Flip it on, with both OAuth providers configured.
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", True)
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "google-test-id")
monkeypatch.setattr(settings, "MS_CLIENT_ID", "ms-test-id")
response = await client.get("/api/v1/config/public")
assert response.status_code == 200
body = response.json()
assert body["self_serve_enabled"] is True
assert body["oauth_providers"] == ["google", "microsoft"]
# Only Microsoft configured.
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
monkeypatch.setattr(settings, "MS_CLIENT_ID", "ms-test-id")
response = await client.get("/api/v1/config/public")
assert response.status_code == 200
assert response.json()["oauth_providers"] == ["microsoft"]
class TestRegisterInviteCodeGate:
"""Regression + new-behavior tests for /auth/register vs SELF_SERVE_ENABLED."""
@pytest.mark.asyncio
async def test_register_invite_code_required_when_self_serve_disabled(
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
):
"""Pre-self-serve behavior: REQUIRE_INVITE_CODE=True without an
invite code (and no account-invite) must still 400."""
monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True)
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
response = await client.post(
"/api/v1/auth/register",
json={
"email": "no-invite@example.com",
"password": "SecurePass123!",
"name": "No Invite",
},
)
assert response.status_code == 400
assert "invite code is required" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_register_invite_code_optional_when_self_serve_enabled(
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
):
"""Self-serve on: registration succeeds with no invite code even
when REQUIRE_INVITE_CODE is True. The user, personal account, and
a Pro trial subscription are all created."""
monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True)
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", True)
response = await client.post(
"/api/v1/auth/register",
json={
"email": "self-serve@example.com",
"password": "SecurePass123!",
"name": "Self Serve",
},
)
assert response.status_code == 201, response.text
body = response.json()
assert body["email"] == "self-serve@example.com"
assert body["account_role"] == "owner"
assert "account_id" in body

View File

@@ -0,0 +1,98 @@
import pytest
from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock, patch
from sqlalchemy import select
@pytest.mark.asyncio
async def test_register_auto_sends_verification_email(client, test_db):
"""Fresh registration triggers send_email_verification_email."""
with patch(
"app.core.email.EmailService.send_email_verification_email",
new_callable=AsyncMock,
) as mock_send:
response = await client.post("/api/v1/auth/register", json={
"email": "newshop@example.com",
"password": "Verystrong1Pwd",
"name": "New Shop",
})
assert response.status_code in (200, 201), response.json()
mock_send.assert_called_once()
kwargs = mock_send.call_args.kwargs
assert kwargs["to_email"] == "newshop@example.com"
assert "/verify-email?token=" in kwargs["verification_url"]
@pytest.mark.asyncio
async def test_register_with_account_invite_code_email_mismatch_rejected(
client, test_db, test_user
):
"""Invite code is for invited@example.com but user registers with a
different email -> 400 invite_email_mismatch."""
from app.models.account_invite import AccountInvite
import uuid
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
account_id = uuid.UUID(test_user["user_data"]["account_id"])
invite = AccountInvite(
account_id=account_id,
invited_by_id=invited_by_id,
email="invited@example.com",
code="INVITECODE99",
role="engineer",
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
)
test_db.add(invite)
await test_db.commit()
response = await client.post("/api/v1/auth/register", json={
"email": "wrong-email@example.com",
"password": "Verystrong1Pwd",
"name": "Wrong Email",
"account_invite_code": "INVITECODE99",
})
assert response.status_code == 400, response.json()
assert response.json()["detail"]["error"] == "invite_email_mismatch"
@pytest.mark.asyncio
async def test_register_with_account_invite_code_email_match_accepted(
client, test_db, test_user
):
"""Invite code is for invited@example.com - registering with that email
succeeds and joins the existing account."""
from app.models.account_invite import AccountInvite
from app.models.user import User
import uuid
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
account_id = uuid.UUID(test_user["user_data"]["account_id"])
invite = AccountInvite(
account_id=account_id,
invited_by_id=invited_by_id,
email="invited@example.com",
code="INVITECODE100",
role="engineer",
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
)
test_db.add(invite)
await test_db.commit()
with patch(
"app.core.email.EmailService.send_email_verification_email",
new_callable=AsyncMock,
):
response = await client.post("/api/v1/auth/register", json={
"email": "invited@example.com",
"password": "Verystrong1Pwd",
"name": "Invited",
"account_invite_code": "INVITECODE100",
})
assert response.status_code in (200, 201), response.json()
new_user = (await test_db.execute(
select(User).where(User.email == "invited@example.com")
)).scalar_one()
assert new_user.account_id == account_id # joined existing account

View File

@@ -0,0 +1,87 @@
import uuid
import pytest
from datetime import datetime, timezone, timedelta
from sqlalchemy import select
from app.models.user import User
async def _set_user_email_state(test_db, user_id, *, verified_at=None, created_at=None):
user = (await test_db.execute(select(User).where(User.id == user_id))).scalar_one()
user.email_verified_at = verified_at
if created_at is not None:
user.created_at = created_at
await test_db.commit()
@pytest.mark.asyncio
async def test_verified_user_passes(client, test_db, test_user, auth_headers):
user_id = uuid.UUID(test_user["user_data"]["id"])
await _set_user_email_state(test_db, user_id, verified_at=datetime.now(timezone.utc))
response = await client.get("/api/v1/trees", headers=auth_headers)
assert response.status_code != 403
@pytest.mark.asyncio
async def test_unverified_in_grace_passes(client, test_db, test_user, auth_headers):
user_id = uuid.UUID(test_user["user_data"]["id"])
await _set_user_email_state(
test_db, user_id,
verified_at=None,
created_at=datetime.now(timezone.utc) - timedelta(days=2),
)
response = await client.get("/api/v1/trees", headers=auth_headers)
assert response.status_code != 403
@pytest.mark.asyncio
async def test_unverified_past_grace_blocks(client, test_db, test_user, auth_headers):
user_id = uuid.UUID(test_user["user_data"]["id"])
await _set_user_email_state(
test_db, user_id,
verified_at=None,
created_at=datetime.now(timezone.utc) - timedelta(days=10),
)
response = await client.get("/api/v1/trees", headers=auth_headers)
assert response.status_code == 403
body = response.json()
assert body["detail"]["error"] == "email_not_verified"
@pytest.mark.asyncio
async def test_unverified_past_grace_allowlisted_still_passes(client, test_db, test_user, auth_headers):
user_id = uuid.UUID(test_user["user_data"]["id"])
await _set_user_email_state(
test_db, user_id,
verified_at=None,
created_at=datetime.now(timezone.utc) - timedelta(days=10),
)
response = await client.get("/api/v1/auth/me", headers=auth_headers)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_combined_guards_unverified_expired_trial(client, test_db, test_user, auth_headers):
"""A user who is BOTH past grace AND on an expired trial should get blocked
by one of the two guards. Either error is acceptable; we just verify a
refusal."""
from app.models.subscription import Subscription
from sqlalchemy import delete
user_id = uuid.UUID(test_user["user_data"]["id"])
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await _set_user_email_state(
test_db, user_id,
verified_at=None,
created_at=datetime.now(timezone.utc) - timedelta(days=10),
)
# Replace the seeded active sub with an expired trial
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
test_db.add(Subscription(
account_id=account_id, plan="pro", status="trialing",
current_period_end=datetime.now(timezone.utc) - timedelta(hours=1),
))
await test_db.commit()
response = await client.get("/api/v1/trees", headers=auth_headers)
assert response.status_code in (402, 403)

View File

@@ -0,0 +1,45 @@
import uuid
import pytest
from datetime import datetime, timezone, timedelta
from sqlalchemy import select
from app.models.subscription import Subscription
@pytest.mark.asyncio
async def test_expired_trial_is_not_mutated_by_get_current_active_user(
test_db, client, test_user, auth_headers
):
"""The previous deps.py:109 logic mutated trialing→active+free on expiry.
That's gone. An expired-trial Subscription should retain status='trialing'
and current_period_end after any authenticated request."""
account_id = uuid.UUID(test_user["user_data"]["account_id"])
# If a Subscription already exists for this account (e.g. created by
# the register handler), update it; otherwise insert a new one.
existing = await test_db.execute(
select(Subscription).where(Subscription.account_id == account_id)
)
sub = existing.scalar_one_or_none()
expired_end = datetime.now(timezone.utc) - timedelta(hours=1)
if sub is None:
sub = Subscription(
account_id=account_id,
plan="pro",
status="trialing",
current_period_end=expired_end,
)
test_db.add(sub)
else:
sub.plan = "pro"
sub.status = "trialing"
sub.current_period_end = expired_end
await test_db.commit()
# Call any authenticated endpoint that goes through get_current_active_user.
response = await client.get("/api/v1/auth/me", headers=auth_headers)
assert response.status_code == 200
await test_db.refresh(sub)
assert sub.status == "trialing"
assert sub.plan == "pro"
assert sub.current_period_end is not None

View File

@@ -13,6 +13,14 @@ pytestmark = pytest.mark.asyncio
@pytest.fixture
async def kb_setup(client, auth_headers, test_db):
"""Seed KB plan limits and return helpers."""
# KB tests were authored against a free-plan user. Phase 1 conftest seeds
# the test_user with a pro/active Subscription; downgrade to free here so
# quota numbers match the original test intent.
from app.models.subscription import Subscription
sub = (await test_db.execute(__import__("sqlalchemy").select(Subscription))).scalar_one()
sub.plan = "free"
await test_db.commit()
# Update plan_limits with KB columns for 'free' plan
await test_db.execute(
__import__("sqlalchemy").text("""

View File

@@ -0,0 +1,196 @@
import uuid
import pytest
from unittest.mock import patch
from sqlalchemy import select
from app.core.security import decode_token, hash_token
from app.models.user import User
from app.models.oauth_identity import OAuthIdentity
from app.models.refresh_token import RefreshToken
from app.models.subscription import Subscription
from app.services.oauth_providers import OAuthProfile
@pytest.mark.asyncio
async def test_google_callback_creates_user_account_subscription(
client, test_db, monkeypatch
):
"""Brand-new user via Google OAuth -> User + Account + Subscription + OAuthIdentity."""
from app.core.config import settings
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
profile = OAuthProfile(
provider_subject="google_subject_123",
email="newuser@example.com",
name="New User",
)
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
response = await client.post(
"/api/v1/auth/google/callback", json={"code": "auth_code_xyz"}
)
assert response.status_code == 200, response.json()
body = response.json()
assert body["is_new_user"] is True
assert body["access_token"]
user = (await test_db.execute(
select(User).where(User.email == "newuser@example.com")
)).scalar_one()
assert user.password_hash is None
assert user.email_verified_at is not None
identity = (await test_db.execute(
select(OAuthIdentity).where(OAuthIdentity.user_id == user.id)
)).scalar_one()
assert identity.provider == "google"
assert identity.provider_subject == "google_subject_123"
sub = (await test_db.execute(
select(Subscription).where(Subscription.account_id == user.account_id)
)).scalar_one()
assert sub.status == "trialing"
assert sub.plan == "pro"
@pytest.mark.asyncio
async def test_google_callback_existing_user_is_idempotent(
client, test_db, test_user, monkeypatch
):
"""When test_user's email is already registered, OAuth links + returns the
same user. Two calls with same provider_subject must not duplicate
OAuthIdentity rows."""
from app.core.config import settings
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
user_id = uuid.UUID(test_user["user_data"]["id"])
email = test_user["email"]
name = test_user["user_data"]["name"]
profile = OAuthProfile(
provider_subject="google_subject_456",
email=email,
name=name,
)
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
r1 = await client.post("/api/v1/auth/google/callback", json={"code": "x"})
r2 = await client.post("/api/v1/auth/google/callback", json={"code": "x"})
assert r1.status_code == 200
assert r2.status_code == 200
assert r1.json()["is_new_user"] is False
assert r2.json()["is_new_user"] is False
identities = (await test_db.execute(
select(OAuthIdentity).where(OAuthIdentity.user_id == user_id)
)).scalars().all()
assert len(identities) == 1
@pytest.mark.asyncio
async def test_google_callback_503_when_unconfigured(client, monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
response = await client.post(
"/api/v1/auth/google/callback", json={"code": "x"}
)
assert response.status_code == 503
@pytest.mark.asyncio
async def test_microsoft_callback_creates_user(client, test_db, monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "MS_CLIENT_ID", "client_dummy")
monkeypatch.setattr(settings, "MS_CLIENT_SECRET", "secret_dummy")
profile = OAuthProfile(
provider_subject="ms_subject_789",
email="msuser@example.com",
name="MS User",
)
with patch("app.api.endpoints.oauth.microsoft_exchange_code", return_value=profile):
response = await client.post(
"/api/v1/auth/microsoft/callback", json={"code": "auth_code"}
)
assert response.status_code == 200, response.json()
user = (await test_db.execute(
select(User).where(User.email == "msuser@example.com")
)).scalar_one()
identity = (await test_db.execute(
select(OAuthIdentity).where(OAuthIdentity.user_id == user.id)
)).scalar_one()
assert identity.provider == "microsoft"
@pytest.mark.asyncio
async def test_oauth_google_callback_stores_refresh_token_jti(
client, test_db, monkeypatch
):
"""A successful Google OAuth callback must persist the refresh-token JTI
in the refresh_tokens table — otherwise /auth/refresh rejects it."""
from app.core.config import settings
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
profile = OAuthProfile(
provider_subject="google_subject_jti_test",
email="jtitest@example.com",
name="JTI Test",
)
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
response = await client.post(
"/api/v1/auth/google/callback", json={"code": "auth_code_xyz"}
)
assert response.status_code == 200, response.json()
body = response.json()
refresh_token_str = body["refresh_token"]
payload = decode_token(refresh_token_str)
assert payload is not None
jti = payload["jti"]
token_hash = hash_token(jti)
user = (await test_db.execute(
select(User).where(User.email == "jtitest@example.com")
)).scalar_one()
stored = (await test_db.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)).scalar_one_or_none()
assert stored is not None, "OAuth callback did not persist refresh-token JTI"
assert stored.user_id == user.id
assert stored.revoked_at is None
@pytest.mark.asyncio
async def test_oauth_refresh_works_after_oauth_signup(
client, test_db, monkeypatch
):
"""End-to-end: OAuth callback issues a refresh token; calling /auth/refresh
with that token must succeed (not be rejected as revoked)."""
from app.core.config import settings
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
profile = OAuthProfile(
provider_subject="google_subject_refresh_test",
email="refresh-after-oauth@example.com",
name="Refresh After OAuth",
)
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
callback_resp = await client.post(
"/api/v1/auth/google/callback", json={"code": "auth_code_xyz"}
)
assert callback_resp.status_code == 200, callback_resp.json()
refresh_token_str = callback_resp.json()["refresh_token"]
refresh_resp = await client.post(
"/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {refresh_token_str}"},
)
assert refresh_resp.status_code == 200, refresh_resp.json()
refreshed = refresh_resp.json()
assert refreshed["access_token"]
assert refreshed["refresh_token"]
# Token rotation: new refresh token differs from the original.
assert refreshed["refresh_token"] != refresh_token_str

View File

@@ -0,0 +1,39 @@
import uuid
import pytest
from sqlalchemy import select
from app.models.oauth_identity import OAuthIdentity
@pytest.mark.asyncio
async def test_oauth_identity_unique_provider_subject(test_db, test_user):
"""Two rows with same provider+subject should violate uniqueness."""
user_id = uuid.UUID(test_user["user_data"]["id"])
row1 = OAuthIdentity(
user_id=user_id,
provider="google",
provider_subject="abc-123",
provider_email_at_link="alex@acmemsp.com",
)
test_db.add(row1)
await test_db.commit()
row2 = OAuthIdentity(
user_id=user_id,
provider="google",
provider_subject="abc-123",
provider_email_at_link="alex@acmemsp.com",
)
test_db.add(row2)
with pytest.raises(Exception): # IntegrityError
await test_db.commit()
await test_db.rollback()
rows = (
await test_db.execute(
select(OAuthIdentity).where(OAuthIdentity.user_id == user_id)
)
).scalars().all()
assert len(rows) == 1

View File

@@ -0,0 +1,83 @@
import uuid
import pytest
from sqlalchemy import select
from app.models.user import User
from app.models.account import Account
from app.models.oauth_identity import OAuthIdentity
async def _make_oauth_only_user(test_db, email, *, with_identity=True):
"""Create an OAuth-only user (password_hash=None) directly in the test DB."""
import secrets
account = Account(
name=f"{email}-acct",
display_code=secrets.token_hex(4).upper(),
)
test_db.add(account)
await test_db.flush()
user = User(
email=email,
name="OAuth User",
password_hash=None,
account_id=account.id,
account_role="owner",
)
test_db.add(user)
await test_db.flush()
if with_identity:
test_db.add(OAuthIdentity(
user_id=user.id, provider="google",
provider_subject=f"google_{email}",
provider_email_at_link=email,
))
await test_db.commit()
return user
@pytest.mark.asyncio
async def test_login_form_rejects_oauth_only_user_with_helpful_error(client, test_db):
await _make_oauth_only_user(test_db, "oauth-only@example.com")
response = await client.post(
"/api/v1/auth/login",
data={"username": "oauth-only@example.com", "password": "wontwork"},
)
assert response.status_code == 400
body = response.json()
assert body["detail"]["error"] == "use_oauth_provider"
assert "google" in body["detail"]["providers"]
@pytest.mark.asyncio
async def test_login_json_rejects_oauth_only_user(client, test_db):
await _make_oauth_only_user(test_db, "oauth-only2@example.com")
response = await client.post(
"/api/v1/auth/login/json",
json={"email": "oauth-only2@example.com", "password": "wontwork"},
)
assert response.status_code == 400
assert response.json()["detail"]["error"] == "use_oauth_provider"
@pytest.mark.asyncio
async def test_password_forgot_silent_for_oauth_only_user(client, test_db):
"""OAuth-only users get the generic message; no email is sent."""
await _make_oauth_only_user(test_db, "oauth-forgot@example.com", with_identity=False)
from unittest.mock import AsyncMock, patch
with patch("app.core.email.EmailService.send_password_reset_email", new_callable=AsyncMock) as mock_send:
response = await client.post(
"/api/v1/auth/password/forgot",
json={"email": "oauth-forgot@example.com"},
)
assert response.status_code == 200
mock_send.assert_not_called()
@pytest.mark.asyncio
async def test_login_for_password_user_still_works(client, test_user):
"""Regression: existing password-based login must still succeed."""
response = await client.post(
"/api/v1/auth/login/json",
json={"email": test_user["email"], "password": test_user["password"]},
)
assert response.status_code == 200
assert response.json()["access_token"]

View File

@@ -1,6 +1,11 @@
"""Tests for onboarding status endpoints."""
from datetime import datetime, timezone
import pytest
from sqlalchemy import select
from app.models.user import User
@pytest.mark.asyncio
@@ -21,6 +26,42 @@ async def test_onboarding_status_fresh_user(client, auth_headers):
assert data["connected_psa"] is False
assert data["is_team_user"] is False
assert data["dismissed"] is False
# Phase 2 fields default to false on a fresh, unverified user with no wizard progress.
assert data["email_verified"] is False
assert data["shop_setup_done"] is False
@pytest.mark.asyncio
async def test_onboarding_status_includes_email_verified_and_shop_setup_done(
client, auth_headers, test_user, test_db
):
"""email_verified flips when email_verified_at is set; shop_setup_done flips at step >= 1."""
# Sanity-check baseline.
response = await client.get(
"/api/v1/users/onboarding-status",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["email_verified"] is False
assert data["shop_setup_done"] is False
# Mutate the underlying user, then re-fetch.
user_email = test_user["email"]
result = await test_db.execute(select(User).where(User.email == user_email))
user = result.scalar_one()
user.email_verified_at = datetime.now(tz=timezone.utc)
user.onboarding_step_completed = 1
await test_db.commit()
response = await client.get(
"/api/v1/users/onboarding-status",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["email_verified"] is True
assert data["shop_setup_done"] is True
@pytest.mark.asyncio

View File

@@ -0,0 +1,149 @@
"""Tests for welcome-wizard onboarding-step endpoints (Phase 2)."""
import pytest
from sqlalchemy import select
from app.models.account import Account
from app.models.user import User
@pytest.mark.asyncio
async def test_onboarding_step1_complete_writes_account_name_and_team_size_and_role(
client, auth_headers, test_db, test_user
):
"""Step 1 + complete writes account.name + team_size_bucket + user.role_at_signup
and advances onboarding_step_completed to 1."""
response = await client.patch(
"/api/v1/users/me/onboarding-step",
headers=auth_headers,
json={
"step": 1,
"action": "complete",
"data": {
"company_name": "Acme MSP",
"team_size_bucket": "3-5",
"role_at_signup": "owner",
},
},
)
assert response.status_code == 200, response.text
data = response.json()
assert data["onboarding_step_completed"] == 1
assert data["onboarding_dismissed"] is False
# Verify persisted writes
account_id = test_user["user_data"]["account_id"]
user_email = test_user["email"]
acct = (
await test_db.execute(select(Account).where(Account.id == account_id))
).scalar_one()
assert acct.name == "Acme MSP"
assert acct.team_size_bucket == "3-5"
user = (
await test_db.execute(select(User).where(User.email == user_email))
).scalar_one()
assert user.role_at_signup == "owner"
assert user.onboarding_step_completed == 1
@pytest.mark.asyncio
async def test_onboarding_step2_skip_advances_without_psa(
client, auth_headers, test_db, test_user
):
"""Step 2 + skip ignores data entirely and only advances the step counter
(no primary_psa write)."""
# Capture original account.primary_psa so we can assert it's untouched.
account_id = test_user["user_data"]["account_id"]
acct_before = (
await test_db.execute(select(Account).where(Account.id == account_id))
).scalar_one()
psa_before = acct_before.primary_psa # likely None
# Advance step 1 first so step 2 is allowed.
r1 = await client.patch(
"/api/v1/users/me/onboarding-step",
headers=auth_headers,
json={"step": 1, "action": "skip"},
)
assert r1.status_code == 200, r1.text
# Skip step 2 — even if data is present it must be ignored.
r2 = await client.patch(
"/api/v1/users/me/onboarding-step",
headers=auth_headers,
json={
"step": 2,
"action": "skip",
"data": {"primary_psa": "connectwise"},
},
)
assert r2.status_code == 200, r2.text
assert r2.json()["onboarding_step_completed"] == 2
# Re-fetch account: primary_psa must NOT have been written.
test_db.expire_all()
acct_after = (
await test_db.execute(select(Account).where(Account.id == account_id))
).scalar_one()
assert acct_after.primary_psa == psa_before
@pytest.mark.asyncio
async def test_onboarding_step_cannot_decrease(client, auth_headers):
"""A step=2 PATCH followed by step=1 must return 400."""
# Advance to step 2.
r1 = await client.patch(
"/api/v1/users/me/onboarding-step",
headers=auth_headers,
json={"step": 1, "action": "skip"},
)
assert r1.status_code == 200, r1.text
r2 = await client.patch(
"/api/v1/users/me/onboarding-step",
headers=auth_headers,
json={"step": 2, "action": "skip"},
)
assert r2.status_code == 200, r2.text
assert r2.json()["onboarding_step_completed"] == 2
# Try to go back to step 1 — must fail.
r3 = await client.patch(
"/api/v1/users/me/onboarding-step",
headers=auth_headers,
json={"step": 1, "action": "skip"},
)
assert r3.status_code == 400, r3.text
# Idempotent re-PATCH of same step succeeds.
r4 = await client.patch(
"/api/v1/users/me/onboarding-step",
headers=auth_headers,
json={"step": 2, "action": "skip"},
)
assert r4.status_code == 200, r4.text
assert r4.json()["onboarding_step_completed"] == 2
@pytest.mark.asyncio
async def test_onboarding_dismiss_rest_sets_flag(
client, auth_headers, test_db, test_user
):
"""POST /users/me/onboarding-dismiss-rest sets users.onboarding_dismissed=TRUE."""
response = await client.post(
"/api/v1/users/me/onboarding-dismiss-rest",
headers=auth_headers,
)
assert response.status_code == 200, response.text
data = response.json()
assert data["onboarding_dismissed"] is True
# step counter is whatever it was (None for a fresh user).
assert "onboarding_step_completed" in data
# Verify persisted.
user_email = test_user["email"]
user = (
await test_db.execute(select(User).where(User.email == user_email))
).scalar_one()
assert user.onboarding_dismissed is True

View File

@@ -0,0 +1,85 @@
"""Smoke test for the complimentary backfill: assertions about the post-state.
The actual migration runs at deploy time; tests use create_all so the
migration body isn't executed automatically. We invoke the SQL inline to
exercise the same effect."""
import uuid
import pytest
from sqlalchemy import select, text, delete
from app.models.account import Account
from app.models.subscription import Subscription
@pytest.mark.asyncio
async def test_complimentary_backfill_sets_status_and_inserts_missing_rows(test_db):
"""Inline-run the backfill SQL and assert post-state."""
# Seed a fresh account with no subscription
no_sub_account = Account(name="NoSub", display_code="NOSUB001")
test_db.add(no_sub_account)
await test_db.flush()
# Seed an account with a trialing subscription (should become complimentary)
trial_account = Account(name="Trial", display_code="TRIAL001")
test_db.add(trial_account)
await test_db.flush()
test_db.add(Subscription(
account_id=trial_account.id, plan="free", status="trialing",
))
# Seed an account with a canceled subscription (should be preserved)
canceled_account = Account(name="Cancel", display_code="CANCL001")
test_db.add(canceled_account)
await test_db.flush()
test_db.add(Subscription(
account_id=canceled_account.id, plan="pro", status="canceled",
))
await test_db.commit()
# Run the same SQL the migration runs
await test_db.execute(text("""
UPDATE subscriptions
SET status = 'complimentary', plan = 'pro',
current_period_end = NULL, current_period_start = NULL,
updated_at = now()
WHERE status NOT IN ('canceled', 'past_due')
"""))
await test_db.execute(text("""
INSERT INTO subscriptions (id, account_id, plan, status, cancel_at_period_end, created_at, updated_at)
SELECT gen_random_uuid(), a.id, 'pro', 'complimentary', false, now(), now()
FROM accounts a
WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.account_id = a.id)
"""))
await test_db.commit()
# All three accounts now have a Subscription
no_sub_row = (await test_db.execute(
select(Subscription).where(Subscription.account_id == no_sub_account.id)
)).scalar_one()
assert no_sub_row.status == "complimentary"
assert no_sub_row.plan == "pro"
trial_row = (await test_db.execute(
select(Subscription).where(Subscription.account_id == trial_account.id)
)).scalar_one()
assert trial_row.status == "complimentary"
assert trial_row.plan == "pro"
canceled_row = (await test_db.execute(
select(Subscription).where(Subscription.account_id == canceled_account.id)
)).scalar_one()
# Canceled is preserved
assert canceled_row.status == "canceled"
assert canceled_row.plan == "pro"
@pytest.mark.asyncio
async def test_complimentary_subscription_passes_active_subscription_guard(
client, test_db, test_user, auth_headers
):
"""The require_active_subscription guard accepts complimentary status."""
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
test_db.add(Subscription(account_id=account_id, plan="pro", status="complimentary"))
await test_db.commit()
response = await client.get("/api/v1/trees", headers=auth_headers)
assert response.status_code != 402

View File

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

View File

@@ -0,0 +1,134 @@
"""Integration tests for the public Talk-to-Sales endpoint.
POST /api/v1/sales-leads — no auth, rate-limited 5/hour per IP.
"""
from unittest.mock import AsyncMock, patch
import pytest
import sqlalchemy as sa
@pytest.mark.asyncio
async def test_sales_lead_creates_row_and_sends_notification_email(client, test_db):
"""Happy path: row inserted, notification email fired, 201 returned."""
payload = {
"email": "buyer@acme.example",
"name": "Pat Buyer",
"company": "Acme MSP",
"team_size": "11-50",
"message": "We're evaluating ResolutionFlow for our NOC team.",
"source": "pricing_page",
"posthog_distinct_id": "ph_distinct_123",
}
with patch(
"app.api.endpoints.sales_leads.EmailService.send_sales_lead_notification",
new=AsyncMock(return_value=True),
) as mock_email:
response = await client.post("/api/v1/sales-leads", json=payload)
assert response.status_code == 201, response.text
body = response.json()
assert body["status"] == "received"
assert "id" in body
# Notification email was attempted (asyncio.create_task — give it a tick).
import asyncio
await asyncio.sleep(0)
await asyncio.sleep(0)
assert mock_email.await_count == 1
kwargs = mock_email.await_args.kwargs
assert kwargs["to_email"] # default placeholder until cutover
assert kwargs["lead"].email == "buyer@acme.example"
assert kwargs["lead"].source == "pricing_page"
# Row was inserted with normalized email + all fields preserved.
result = await test_db.execute(
sa.text("SELECT email, name, company, team_size, message, source, posthog_distinct_id, status FROM sales_leads")
)
rows = result.all()
assert len(rows) == 1
row = rows[0]
assert row.email == "buyer@acme.example"
assert row.name == "Pat Buyer"
assert row.company == "Acme MSP"
assert row.team_size == "11-50"
assert row.message == "We're evaluating ResolutionFlow for our NOC team."
assert row.source == "pricing_page"
assert row.posthog_distinct_id == "ph_distinct_123"
assert row.status == "new"
@pytest.mark.asyncio
async def test_sales_lead_email_failure_does_not_fail_request(client, test_db):
"""If the email send raises, the API still returns 201 and the row persists."""
payload = {
"email": "buyer2@acme.example",
"name": "Sam Lead",
"company": "Acme MSP",
"source": "register_footer",
}
with patch(
"app.api.endpoints.sales_leads.EmailService.send_sales_lead_notification",
new=AsyncMock(side_effect=RuntimeError("resend exploded")),
):
response = await client.post("/api/v1/sales-leads", json=payload)
assert response.status_code == 201, response.text
# Row must still be persisted even though email failed.
import asyncio
await asyncio.sleep(0)
result = await test_db.execute(
sa.text("SELECT count(*) FROM sales_leads WHERE email = 'buyer2@acme.example'")
)
assert result.scalar() == 1
@pytest.mark.asyncio
async def test_sales_lead_rate_limited_after_5_per_hour(client):
"""The 6th submission within an hour from the same IP returns 429.
The default `limiter` is disabled in tests (DEBUG=true). We re-enable it
for this test, then reset its state on teardown so other tests aren't
affected.
"""
from app.core.rate_limit import limiter
was_enabled = limiter.enabled
limiter.enabled = True
try:
limiter.reset()
with patch(
"app.api.endpoints.sales_leads.EmailService.send_sales_lead_notification",
new=AsyncMock(return_value=True),
):
for i in range(5):
payload = {
"email": f"lead{i}@acme.example",
"name": f"Lead {i}",
"company": "Acme MSP",
"source": "landing_page",
}
resp = await client.post("/api/v1/sales-leads", json=payload)
assert resp.status_code == 201, f"submission {i}: {resp.text}"
# 6th should be rate-limited.
resp = await client.post(
"/api/v1/sales-leads",
json={
"email": "lead6@acme.example",
"name": "Lead 6",
"company": "Acme MSP",
"source": "landing_page",
},
)
assert resp.status_code == 429, resp.text
finally:
limiter.reset()
limiter.enabled = was_enabled

View File

@@ -0,0 +1,319 @@
import json
import uuid
import pytest
from sqlalchemy import delete, select
from unittest.mock import patch
from app.models.subscription import Subscription
def _make_event(event_id, event_type, obj):
return {
"id": event_id,
"type": event_type,
"data": {"object": obj},
}
@pytest.mark.asyncio
async def test_checkout_completed_activates_subscription(
client, test_db, test_user, auth_headers, monkeypatch
):
from app.core.config import settings
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
monkeypatch.setattr(settings, "STRIPE_WEBHOOK_SECRET", "whsec_dummy")
account_id = uuid.UUID(test_user["user_data"]["account_id"])
# Replace seeded sub with trialing + stripe_customer_id linkage
from app.models.account import Account
account = (await test_db.execute(select(Account).where(Account.id == account_id))).scalar_one()
account.stripe_customer_id = "cus_xxx"
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
test_db.add(Subscription(account_id=account_id, plan="pro", status="trialing"))
await test_db.commit()
event = _make_event("evt_co_1", "checkout.session.completed", {
"id": "cs_xxx",
"customer": "cus_xxx",
"subscription": "sub_xxx",
})
with patch("stripe.Subscription.retrieve", return_value={
"id": "sub_xxx",
"status": "active",
"current_period_start": 1714521600,
"current_period_end": 1717113600,
"items": {"data": [{
"price": {"id": "price_test_monthly"},
"quantity": 5,
}]},
"cancel_at_period_end": False,
}), patch("stripe.Webhook.construct_event", return_value=event):
response = await client.post(
"/api/v1/webhooks/stripe",
content=json.dumps(event),
headers={"stripe-signature": "fake-sig"},
)
assert response.status_code == 200, response.json()
sub = (await test_db.execute(
select(Subscription).where(Subscription.account_id == account_id)
)).scalar_one()
assert sub.status == "active"
assert sub.stripe_subscription_id == "sub_xxx"
@pytest.mark.asyncio
async def test_subscription_deleted_cancels_account(
client, test_db, test_user, auth_headers, monkeypatch
):
from app.core.config import settings
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
monkeypatch.setattr(settings, "STRIPE_WEBHOOK_SECRET", "whsec_dummy")
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
test_db.add(Subscription(
account_id=account_id, plan="pro", status="active",
stripe_subscription_id="sub_xxx",
))
await test_db.commit()
event = _make_event("evt_del_1", "customer.subscription.deleted", {
"id": "sub_xxx",
"current_period_start": 1714521600,
"current_period_end": 1717113600,
"items": {"data": [{"quantity": 1}]},
})
with patch("stripe.Webhook.construct_event", return_value=event):
response = await client.post(
"/api/v1/webhooks/stripe",
content=json.dumps(event),
headers={"stripe-signature": "fake-sig"},
)
assert response.status_code == 200
sub = (await test_db.execute(
select(Subscription).where(Subscription.account_id == account_id)
)).scalar_one()
assert sub.status == "canceled"
@pytest.mark.asyncio
async def test_webhook_signature_failure_returns_400(client, monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
monkeypatch.setattr(settings, "STRIPE_WEBHOOK_SECRET", "whsec_dummy")
with patch("stripe.Webhook.construct_event", side_effect=ValueError("bad sig")):
response = await client.post(
"/api/v1/webhooks/stripe",
content=b"{}",
headers={"stripe-signature": "fake-sig"},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_webhook_idempotency(
client, test_db, test_user, auth_headers, monkeypatch
):
from app.core.config import settings
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
monkeypatch.setattr(settings, "STRIPE_WEBHOOK_SECRET", "whsec_dummy")
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
test_db.add(Subscription(account_id=account_id, plan="pro", status="trialing"))
await test_db.commit()
event = _make_event("evt_dup_1", "customer.subscription.updated", {
"id": "sub_yyy",
"status": "active",
"current_period_start": 1714521600,
"current_period_end": 1717113600,
"items": {"data": [{"quantity": 1}]},
"cancel_at_period_end": False,
})
with patch("stripe.Webhook.construct_event", return_value=event):
r1 = await client.post("/api/v1/webhooks/stripe", content=json.dumps(event), headers={"stripe-signature": "x"})
r2 = await client.post("/api/v1/webhooks/stripe", content=json.dumps(event), headers={"stripe-signature": "x"})
assert r1.status_code == 200
assert r2.status_code == 200
assert r1.json()["applied"] is True
assert r2.json()["applied"] is False
# ----------------------------------------------------------------------------
# Atomic-idempotency regression tests
# ----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_apply_event_handler_failure_does_not_persist_idempotency_mark(
test_db, test_user,
):
"""If the handler raises, the StripeEvent row must NOT be persisted —
otherwise Stripe's retry will be silently dropped as a duplicate and the
subscription state will desync from Stripe."""
from app.services.billing import BillingService
from app.models.stripe_event import StripeEvent
event_id = "evt_handler_fail_1"
payload = {"data": {"object": {
"id": "sub_doesnotmatter",
"status": "active",
"current_period_start": 1714521600,
"current_period_end": 1717113600,
"items": {"data": [{"quantity": 1}]},
"cancel_at_period_end": False,
}}}
boom = RuntimeError("simulated handler failure")
with patch(
"app.services.billing._handle_subscription_updated",
side_effect=boom,
):
with pytest.raises(RuntimeError, match="simulated handler failure"):
await BillingService.apply_subscription_event(
test_db,
event_id=event_id,
event_type="customer.subscription.updated",
payload=payload,
)
# The StripeEvent row must not exist — handler raised, the entire
# transaction (idempotency mark + partial mutations) was rolled back.
row = (await test_db.execute(
select(StripeEvent).where(StripeEvent.id == event_id)
)).scalar_one_or_none()
assert row is None, (
"StripeEvent row was persisted despite handler failure — "
"Stripe retry will be silently dropped"
)
@pytest.mark.asyncio
async def test_apply_event_retry_after_failure_succeeds(
test_db, test_user,
):
"""A failed first attempt followed by a successful retry must apply state.
This is the core Stripe webhook retry contract."""
from app.services.billing import BillingService
from app.models.stripe_event import StripeEvent
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
test_db.add(Subscription(
account_id=account_id, plan="pro", status="trialing",
stripe_subscription_id="sub_retry",
))
await test_db.commit()
event_id = "evt_retry_1"
payload = {"data": {"object": {
"id": "sub_retry",
"status": "active",
"current_period_start": 1714521600,
"current_period_end": 1717113600,
"items": {"data": [{"quantity": 3}]},
"cancel_at_period_end": False,
}}}
# First attempt — handler raises mid-flight.
with patch(
"app.services.billing._handle_subscription_updated",
side_effect=RuntimeError("transient blip"),
):
with pytest.raises(RuntimeError):
await BillingService.apply_subscription_event(
test_db,
event_id=event_id,
event_type="customer.subscription.updated",
payload=payload,
)
# No idempotency mark, sub still trialing.
row = (await test_db.execute(
select(StripeEvent).where(StripeEvent.id == event_id)
)).scalar_one_or_none()
assert row is None
sub = (await test_db.execute(
select(Subscription).where(Subscription.account_id == account_id)
)).scalar_one()
assert sub.status == "trialing"
# Second attempt — same event_id, handler succeeds.
applied = await BillingService.apply_subscription_event(
test_db,
event_id=event_id,
event_type="customer.subscription.updated",
payload=payload,
)
assert applied is True
# Idempotency mark now persisted, sub state reconciled.
row = (await test_db.execute(
select(StripeEvent).where(StripeEvent.id == event_id)
)).scalar_one()
assert row.id == event_id
await test_db.refresh(sub)
assert sub.status == "active"
assert sub.seat_limit == 3
@pytest.mark.asyncio
async def test_apply_event_duplicate_event_id_skips(
test_db, test_user,
):
"""Two successful invocations with the same event_id must not double-apply.
Second call returns False; mutations are not repeated."""
from app.services.billing import BillingService
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
test_db.add(Subscription(
account_id=account_id, plan="pro", status="trialing",
stripe_subscription_id="sub_dup",
))
await test_db.commit()
event_id = "evt_dedupe_1"
payload = {"data": {"object": {
"id": "sub_dup",
"status": "active",
"current_period_start": 1714521600,
"current_period_end": 1717113600,
"items": {"data": [{"quantity": 7}]},
"cancel_at_period_end": False,
}}}
applied1 = await BillingService.apply_subscription_event(
test_db,
event_id=event_id,
event_type="customer.subscription.updated",
payload=payload,
)
assert applied1 is True
sub = (await test_db.execute(
select(Subscription).where(Subscription.account_id == account_id)
)).scalar_one()
assert sub.status == "active"
assert sub.seat_limit == 7
# Mutate locally so we can prove the second call doesn't re-run the handler.
sub.seat_limit = 99
await test_db.commit()
applied2 = await BillingService.apply_subscription_event(
test_db,
event_id=event_id,
event_type="customer.subscription.updated",
payload=payload,
)
assert applied2 is False
await test_db.refresh(sub)
# Handler did NOT run again — our local mutation is preserved.
assert sub.seat_limit == 99

View File

@@ -0,0 +1,89 @@
"""Tests for require_active_subscription dependency.
Verifies the 402 gating logic for Pro-guarded routers and the allowlist
that lets billing/account/auth flows through even when locked.
"""
import uuid
import pytest
from datetime import datetime, timezone, timedelta
from sqlalchemy import delete
from app.models.subscription import Subscription
async def _set_subscription(test_db, account_id, **fields):
"""Replace any existing Subscription on the account with one matching `fields`."""
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
test_db.add(Subscription(account_id=account_id, **fields))
await test_db.commit()
@pytest.mark.asyncio
async def test_active_subscription_passes(client, test_db, test_user, auth_headers):
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await _set_subscription(test_db, account_id, plan="pro", status="active")
response = await client.get("/api/v1/trees", headers=auth_headers)
assert response.status_code != 402
@pytest.mark.asyncio
async def test_complimentary_subscription_passes(client, test_db, test_user, auth_headers):
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await _set_subscription(test_db, account_id, plan="pro", status="complimentary")
response = await client.get("/api/v1/trees", headers=auth_headers)
assert response.status_code != 402
@pytest.mark.asyncio
async def test_trialing_unexpired_passes(client, test_db, test_user, auth_headers):
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await _set_subscription(
test_db, account_id,
plan="pro", status="trialing",
current_period_end=datetime.now(timezone.utc) + timedelta(days=5),
)
response = await client.get("/api/v1/trees", headers=auth_headers)
assert response.status_code != 402
@pytest.mark.asyncio
async def test_trialing_expired_returns_402(client, test_db, test_user, auth_headers):
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await _set_subscription(
test_db, account_id,
plan="pro", status="trialing",
current_period_end=datetime.now(timezone.utc) - timedelta(hours=1),
)
response = await client.get("/api/v1/trees", headers=auth_headers)
assert response.status_code == 402
body = response.json()
assert body["detail"]["error"] == "subscription_inactive"
@pytest.mark.asyncio
async def test_canceled_returns_402(client, test_db, test_user, auth_headers):
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await _set_subscription(test_db, account_id, plan="pro", status="canceled")
response = await client.get("/api/v1/trees", headers=auth_headers)
assert response.status_code == 402
@pytest.mark.asyncio
async def test_no_subscription_returns_402(client, test_db, test_user, auth_headers):
account_id = uuid.UUID(test_user["user_data"]["account_id"])
# Remove the seeded default subscription
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
await test_db.commit()
response = await client.get("/api/v1/trees", headers=auth_headers)
assert response.status_code == 402
body = response.json()
assert body["detail"]["error"] == "no_subscription"
@pytest.mark.asyncio
async def test_auth_me_bypasses_guard(client, test_db, test_user, auth_headers):
"""Allowlisted route works even when subscription is canceled."""
account_id = uuid.UUID(test_user["user_data"]["account_id"])
await _set_subscription(test_db, account_id, plan="pro", status="canceled")
response = await client.get("/api/v1/auth/me", headers=auth_headers)
assert response.status_code == 200

View File

@@ -10,8 +10,15 @@ class TestSubscriptionLimits:
"""Test suite for subscription plan limits."""
@pytest.mark.asyncio
async def test_free_plan_tree_limit(self, client: AsyncClient, auth_headers: dict):
async def test_free_plan_tree_limit(
self, client: AsyncClient, auth_headers: dict, test_db: AsyncSession
):
"""Test that free plan has tree creation limit of 3."""
from app.models.subscription import Subscription
sub = (await test_db.execute(select(Subscription))).scalar_one()
sub.plan = "free"
await test_db.commit()
tree_template = {
"name": "Limit Test Tree",
"tree_structure": {
@@ -90,8 +97,15 @@ class TestSubscriptionLimits:
assert response.status_code == 201
@pytest.mark.asyncio
async def test_free_plan_limits_correct(self, client: AsyncClient, auth_headers: dict):
async def test_free_plan_limits_correct(
self, client: AsyncClient, auth_headers: dict, test_db: AsyncSession
):
"""Test that free plan limits are correct."""
from app.models.subscription import Subscription
sub = (await test_db.execute(select(Subscription))).scalar_one()
sub.plan = "free"
await test_db.commit()
response = await client.get("/api/v1/accounts/me/subscription", headers=auth_headers)
assert response.status_code == 200
limits = response.json()["limits"]

View File

@@ -0,0 +1,41 @@
from datetime import datetime, timezone, timedelta
from app.models.subscription import Subscription
def make_sub(**kwargs):
sub = Subscription()
sub.plan = kwargs.get("plan", "free")
sub.status = kwargs.get("status", "active")
sub.current_period_end = kwargs.get("current_period_end")
return sub
def test_complimentary_is_active_but_not_paid():
sub = make_sub(plan="pro", status="complimentary")
assert sub.is_active is True
assert sub.is_paid is False
assert sub.has_pro_entitlement is True
def test_paid_pro_active():
sub = make_sub(plan="pro", status="active")
assert sub.is_paid is True
assert sub.has_pro_entitlement is True
def test_trial_unexpired_has_entitlement():
sub = make_sub(plan="pro", status="trialing", current_period_end=datetime.now(timezone.utc) + timedelta(days=5))
assert sub.is_active is True
assert sub.is_paid is False
assert sub.has_pro_entitlement is True
def test_trial_expired_no_entitlement():
sub = make_sub(plan="pro", status="trialing", current_period_end=datetime.now(timezone.utc) - timedelta(hours=1))
assert sub.has_pro_entitlement is False
def test_canceled_no_entitlement():
sub = make_sub(plan="pro", status="canceled")
assert sub.is_active is False
assert sub.has_pro_entitlement is False

View File

@@ -12,13 +12,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.account import Account
from app.models.user import User
from app.models.tree import Tree
from app.models.subscription import Subscription
from app.core.security import get_password_hash
# ── Helpers ──────────────────────────────────────────────────────────────────
async def _create_account_and_user(db: AsyncSession, prefix: str):
"""Create a fresh account + engineer user. Returns (account, user, plain_password)."""
"""Create a fresh account + engineer user. Returns (account, user, plain_password).
Seeds a default active Pro Subscription for the account so requests pass
the require_active_subscription guard added in Phase 1 Task 11.
"""
password = "TestPass123!"
account = Account(
name=f"{prefix}-corp",
@@ -36,6 +41,7 @@ async def _create_account_and_user(db: AsyncSession, prefix: str):
account_role="engineer",
)
db.add(user)
db.add(Subscription(account_id=account.id, plan="pro", status="active"))
await db.flush()
return account, user, password
@@ -168,6 +174,7 @@ async def test_ai_session_search_cannot_see_other_users_sessions(
account = Account(name="Shared Corp", display_code=uuid.uuid4().hex[:8])
test_db.add(account)
await test_db.flush()
test_db.add(Subscription(account_id=account.id, plan="pro", status="active"))
password = "TestPass123!"
user_a = User(

View File

@@ -0,0 +1,23 @@
import pytest
from app.models.user import User
from app.models.account import Account
@pytest.mark.asyncio
async def test_user_can_be_created_without_password_hash(test_db):
"""OAuth-only users have password_hash=None and the row should commit cleanly."""
account = Account(name="OAuthShop", display_code="OAUTH001")
test_db.add(account)
await test_db.flush()
user = User(
email="oauth-only@example.com",
name="OAuth Only",
password_hash=None,
account_id=account.id,
account_role="engineer",
)
test_db.add(user)
await test_db.commit()
await test_db.refresh(user)
assert user.password_hash is None

View File

@@ -0,0 +1,81 @@
# Issue Cleanup Plan - 2026-05-01
## Tracker Hygiene
These are safe tracker updates before any feature work:
1. Close Gitea #127 (`feat: show AI content scope indicator`) unless an always-visible badge is still desired.
- Current code already has IT/MSP scope copy in the assistant empty state.
- `ASSISTANT_SYSTEM_PROMPT` also has an off-domain redirect boundary.
2. Rewrite Gitea #66 (`Tree Templates + Import/Export`) to the remaining scope only.
- `.rfflow` export/import is implemented in `tree_transfer.py` and exposed in the library UI.
- Remaining work: curated packs, authenticated one-click install from gallery, template versioning, marketplace/community path.
3. Close or archive open PR #124 (`feat/cockpit-harness`).
- It is unmergeable against current `main` and overlaps newer `/pilot` work.
4. Keep Gitea #58, #60, #128, #129, #130 open.
- They still describe real product gaps.
## Recommended Order
### 1. Low-Risk Maintenance
- Status: started 2026-05-01.
- Frontend lint is clean after removing stale disable comments and tightening hook dependencies.
- Added `data-testid` selectors for e2e-critical session history and FlowPilot command-palette controls.
- Added `AssistantChatPage` observability for unexpected `currentChatRef` guard mismatches so stale async discards are visible in the console.
Why first: these reduce future regression cost and are small, well-bounded changes.
### 2. Pilot UX Friction
- Status: started 2026-05-01.
- #130: Added diagnostic command help affordances in `TaskLane` action cards. Each active diagnostic card can explain what it checks, what to look for, and when to use it.
- #128: Keep the existing responsive drawer behavior for now. `TaskLane` already uses a side panel on wide screens and a bottom drawer below the desktop breakpoint; do not add a top/side preference unless pilot feedback shows the current responsive layout is blocking workflow.
- EscalationQueue mobile design stays deferred until a customer asks for it.
Why second: this improves the current FlowPilot wedge without changing core data models.
Validation run:
- `docker exec -w /app resolutionflow_frontend npm run lint`
- `docker exec -w /app resolutionflow_frontend npx tsc -b`
- `docker exec -w /app resolutionflow_frontend npm run build`
### 3. Workflow Quality Signals
- #58: Add structured "step is wrong" flags separate from thumbs-up/down helpfulness.
- Existing `StepFeedback` is not enough; it only records helpful/unhelpful and cannot capture incorrect/outdated/unclear/missing-info reasons.
Why third: useful, but needs schema/API/UI/admin surfaces.
### 4. Client Intelligence
- #60: Recurring issue detection.
- Start with a read-only banner using existing `sessions.client_name + tree_id` filters.
- Add same-resolution detection only after confirming the available session outcome/node data is reliable enough.
Why fourth: high value, but it touches session-start and close-out flows and needs careful false-positive handling.
### 5. Documentation Structure
- #129: Hierarchical guide navigation.
- Current `/guides` route is a card grid plus detail pages with sections and breadcrumbs, but not a collapsible guide tree.
Why fifth: valid UX request, but less urgent than pilot workflow gaps.
## Gitea Actions Needed
The current environment does not have a Gitea token configured, so API writes fail with `401 token is required`. Once authenticated:
```bash
curl -X PATCH \
https://gitea.resolutionflow.com/api/v1/repos/chihlasm/resolutionflow/issues/127 \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"state":"closed"}'
```
For #66, prefer editing the title/body instead of closing it:
- Title: `feat: curated template packs and one-click install`
- Body: remove completed `.rfflow` export/import acceptance criteria and keep pack/install/versioning work.

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,904 @@
# Self-Serve Signup & Onboarding — Design Spec
**Date:** 2026-05-05
**Status:** Draft (revised after review-findings pass; pending user re-review)
**Author:** Michael Chihlas + Claude
---
## Overview
Open ResolutionFlow to public self-serve signup with a 14-day reverse trial on Pro, Stripe-backed billing, a sales-assist lane for Enterprise, and a hybrid onboarding flow (3-step welcome wizard + dashboard with next-step card). The current invite-code-gated registration is removed; existing pilot users transition to a permanent `subscriptions.status='complimentary'` state. **The billing layer reuses existing infrastructure** (`subscriptions` + `plan_limits` + `feature_flags` + `plan_feature_defaults` + `account_feature_overrides` + `account_invites` + `email_verification_tokens`) — this spec adds only what's missing, not parallel structures.
---
## Decisions Made
| Question | Decision |
|---|---|
| Trigger for redoing signup/onboarding | Open self-serve channel (D); must look trustworthy; must hook into payment processor cleanly |
| Trial / payment model | A + E — reverse trial (14 days, no card upfront) + sales-assist lane for Enterprise |
| Plan structure | Two self-serve tiers (Starter, Pro) per seat + sales-assist Enterprise. Defined via existing `plan_limits.plan` keys + a new `plan_billing` sibling table (Stripe IDs, prices, public catalog metadata). |
| Payment processor | Stripe with hosted Checkout; no provider abstraction |
| Auth strategy | Stay with custom auth. Extend existing email verification (auto-send on register, 7-day soft grace + dashboard wall). Add Google + Microsoft via new `oauth_identities` table; `users.password_hash` becomes nullable with explicit OAuth-only handling in login/change-password/reset. Extend existing `account_invites` (enforce email match at register, wire `EmailService` into create/bulk). |
| Signup form scope | A — minimal form (treat all signups as team-of-1) |
| Plan choice timing | X — defer; trial runs on full Pro; picker shown around day 12 and at trial-end |
| Feature gating | **Reuse existing `feature_flags` + `plan_feature_defaults` + `account_feature_overrides`.** Admin via existing `/admin/plan-limits` + `/admin/feature-flags` endpoints. No new combined `/admin/plans` surface in v1. |
| Onboarding shape | C — hybrid (3-step welcome wizard then dashboard with checklist) |
| Welcome wizard layout | V2 — narrative 3 steps (Your shop, Your PSA, Invite your team) |
| Dashboard first-run | C — topbar trial pill + single "next step" card (full checklist behind a "Show all" toggle) |
| Email verification | Soft, 7-day grace, hard wall day 7; skipped entirely for OAuth signups (provider-attested). **Reuses existing `email_verification_tokens` table + `/auth/email/send-verification` + `/auth/email/verify`.** Backend enforcement via new `require_verified_email_after_grace` dep with path allowlist (auth, profile, billing) returns 403 when grace expires unverified. Frontend `<EmailVerificationWall />` is a UX layer over the same rule. |
| Pricing page | B — pricing + light marketing context (comparison table + testimonial slot + trust strip) |
| Trial-end conversion flow | A — quiet days 1-9, gentle nudges 10-13, hard wall day 14 with plan picker |
| Trial expiry enforcement | **Replace `deps.py:109` auto-downgrade.** Expiry is computed at request time (`status='trialing' AND current_period_end < now()`); no mutation to `plan='free'`. New backend `require_active_subscription` dep with path allowlist returns 402 when locked. |
| `is_paid` semantics | `subscriptions.is_paid` excludes `complimentary` so comp accounts don't inflate paid/MRR metrics. New `has_pro_entitlement` property covers "this account can access Pro features" (true for paid Pro + complimentary Pro + active trial). |
| Billing state surface | **Separate `GET /billing/state` endpoint** feeding a new frontend `useBillingStore`. `/auth/me` stays user-focused. |
| Teammate invite-accept | Set-password OR Google/Microsoft OAuth; email-locked **(enforced at `/auth/register` against `account_invites.email`)**; no welcome wizard for invitees. |
| Existing pilot users | All transitioned to `subscriptions.status='complimentary'` on Pro — no nags, no walls, voluntary conversion path. |
| Existing invite codes | Registration gate removed. Table preserved for historical pilots; `User.invite_code_id` retained for existing rows; not consumed at new signups. **No repurposing.** |
| Promo codes | **Deferred from v1.** Add a new `promo_codes` table later if a launch campaign needs them. |
---
## Section 1 — System overview
### What this delivers
Public registration through `/pricing``/register``/welcome` → dashboard, with the billing substrate built almost entirely on existing infrastructure. New code is concentrated in (a) the OAuth surface, (b) Stripe-aware billing service + webhook handler, (c) the welcome wizard + dashboard redesign, and (d) the public-facing pricing page.
### Four chunks of work
1. **Front-of-funnel** — public `/pricing` page (B-style: comparison table + testimonial slot + trust strip), sales-lead capture form, reworked `/register` form with OAuth options.
2. **Onboarding surfaces** — 3-step welcome wizard (V2: shop → PSA selection → invite team) firing immediately after register; redesigned dashboard with topbar trial pill + single "next-step" card (C-style); 6-item checklist (Verify email → Setup shop → Run first session → Connect PSA → Invite teammate → Pick a plan).
3. **Billing integration over existing schema** — extend `plan_limits` with a sibling `plan_billing` table (Stripe IDs + public catalog metadata); seed Starter / Pro / Enterprise rows in `plan_limits`; seed `feature_flags` + `plan_feature_defaults` for the Pro/Starter gating split; add `subscriptions.status='complimentary'` value; replace `deps.py:109` trial-expiry mutation with computed checks; add a `BillingService`, Stripe webhook handler, and `require_active_subscription` dep. Reuses existing `/admin/plan-limits` and `/admin/feature-flags` admin surfaces.
4. **Auth additions** — Google + Microsoft OAuth via a new `oauth_identities` table (`users.password_hash` becomes nullable). Extend existing `email_verification_tokens` flow with auto-send on register and a 7-day soft-grace dashboard wall. Extend existing `account_invites` to enforce email match at registration and to actually send the invitation email at create-time (today only resend sends).
### What stays the same
- Existing JWT auth + JTI refresh rotation
- `Account` / `Team` / `User` model and the `is_super_admin` / `account_role` / `is_team_admin` permission hierarchy (with `account_role` enum `'owner' | 'admin' | 'engineer' | 'viewer'`)
- Phase 4 RLS (subscription state lives on `subscriptions`, account-scoped — RLS rules already configured for it)
- All product surfaces (FlowPilot, PSA integrations, sessions, flows)
- `/admin/plan-limits` + `/admin/feature-flags` admin endpoints (extended, not replaced)
- `/accounts/me/transfer-ownership` (existing — covers owner transfer, no longer flagged "out of scope")
- `/accounts/me/invites` and `/me/invites/{id}/resend` (extended with email send + email-match enforcement)
### What's deprecated
- Invite-code-as-registration-gate. The `invite_codes` table is preserved (historical foreign keys from `users.invite_code_id`); the gate is removed at `/auth/register`.
- `beta_signup.py` waitlist endpoint becomes a 307 redirect to `/register`.
- The current SOLO/TEAM split in `OnboardingChecklist` (one unified list).
- The "Check out the Script Builder" item mapped to the stale `tried_ai_assistant` key.
- Custom card-collection forms (Stripe Checkout owns this).
- The auto-downgrade-on-expired-trial logic in `deps.py:109` (replaced with non-mutating computed checks).
### Sequencing principle
The billing extensions (new columns, new dep, replacing the auto-downgrade) and the Stripe webhook handler are the longest pole and the most unfamiliar surface area. Build it first, ship it dark behind `SELF_SERVE_ENABLED=false`, then layer the funnel and onboarding surfaces once it's stable. Detailed phases live in the implementation plan.
---
## Section 2 — Data model
### Schema additions (new, small)
#### `oauth_identities`
```
id UUID PK
user_id UUID FK users
provider VARCHAR(20) -- 'google' | 'microsoft'
provider_subject VARCHAR(255) -- provider's stable user id
provider_email_at_link VARCHAR(255) -- email reported by provider at link time
created_at, updated_at TIMESTAMP WITH TIME ZONE
UNIQUE (provider, provider_subject)
INDEX (user_id)
```
A user can have zero password (OAuth-only), one password, and 0+ OAuth identities. v1 ships with one identity per user (signup creates one row). Account linking is a future feature with no schema change required.
#### `plan_billing` (sibling to `plan_limits`)
```
plan VARCHAR(50) PK FK plan_limits.plan
display_name VARCHAR(255) NOT NULL
description TEXT NULL
monthly_price_cents INTEGER NULL -- nullable for Enterprise (custom)
annual_price_cents INTEGER NULL
stripe_product_id VARCHAR(255) NULL
stripe_monthly_price_id VARCHAR(255) NULL
stripe_annual_price_id VARCHAR(255) NULL
is_public BOOLEAN NOT NULL DEFAULT TRUE
is_archived BOOLEAN NOT NULL DEFAULT FALSE
sort_order INTEGER NOT NULL DEFAULT 0
created_at, updated_at TIMESTAMP WITH TIME ZONE
```
`plan_limits.plan` stays the canonical plan key. `plan_billing` carries the Stripe + public-catalog metadata. Joined into the existing `/admin/plan-limits` admin endpoint via the response schema (single PUT updates both tables in one transaction).
#### `sales_leads`
```
id UUID PK
email VARCHAR(255) INDEXED
name VARCHAR(255)
company VARCHAR(255)
team_size VARCHAR(20) -- range string from form
message TEXT
source VARCHAR(50) -- 'pricing_page' | 'register_footer' | etc.
posthog_distinct_id VARCHAR(255) NULL
status VARCHAR(20) DEFAULT 'new' -- 'new' | 'contacted' | 'closed'
created_at, updated_at
```
Global table. No RLS.
#### `stripe_events`
Webhook idempotency log. Global table.
```
id VARCHAR(255) PK -- Stripe event id
event_type VARCHAR(100) INDEXED
processed_at TIMESTAMP WITH TIME ZONE
payload_excerpt JSONB
```
### Modifications to existing tables
#### `subscriptions` — extend the status enum
- New status value: `'complimentary'`. Status enum effectively becomes `'active' | 'trialing' | 'past_due' | 'canceled' | 'incomplete' | 'complimentary'`. The column is `String(50)` so no schema migration is required for the value itself; we update the value-level checks only.
- `Subscription.is_active` already returns `True` for `('active', 'trialing')` — extend to include `'complimentary'`.
- `Subscription.is_paid` (currently `plan in ('pro', 'team')`) → narrow to `plan in ('pro', 'team') AND status NOT IN ('complimentary',)`. Used for revenue / paid-customer / MRR calculations only.
- New `Subscription.has_pro_entitlement` property: returns True for `(plan='pro' AND status IN ('active', 'complimentary'))` OR `(status='trialing' AND current_period_end > now())`. Used for "can this account access Pro features."
These are model-level Python property changes plus tests; the underlying column type doesn't change.
#### `users` — additions
- `email_verified_at` already exists. No add. Email-verification flow uses it.
- `password_hash`**change `nullable=False` → `nullable=True`** to support OAuth-only users. Migration sets nullable; no data backfill needed (existing rows all have hashes).
- `role_at_signup VARCHAR(50) NULL``'owner' | 'lead_tech' | 'tech' | 'other'` (welcome-wizard Step 1 captures this).
The existing `users.onboarding_dismissed` field stays. **Add a new `users.onboarding_step_completed INTEGER NULL`** that tracks the highest wizard step the user has either completed or explicitly skipped (1, 2, or 3; NULL = haven't started). This is the only new column needed beyond `role_at_signup` and resolves the per-step skip ambiguity that derived data couldn't represent.
Wizard state model:
- User clicks **Continue** on a step → `onboarding_step_completed = step_number`. Step's data fields are written (e.g., Step 1 writes `users.role_at_signup` + `accounts.team_size_bucket`).
- User clicks **Skip** on a step → `onboarding_step_completed = step_number`. Step's data fields stay NULL.
- User clicks **Skip the rest** on any step → `users.onboarding_dismissed = TRUE` (whatever step they were on stays as `onboarding_step_completed = step_number - 1`).
- Wizard is "done" when `onboarding_dismissed = TRUE` OR `onboarding_step_completed >= 3`.
- `/welcome` redirect logic: if done, go to `/`; otherwise go to `/welcome/step-{onboarding_step_completed + 1 or 1}`.
This makes "I intentionally skipped inviting teammates" representable separately from "I haven't reached Step 3 yet."
#### `accounts` — additions for wizard data
`accounts.name` (existing, `String(255) NOT NULL`) is reused for the wizard's "Company name" field — the wizard updates this row rather than a new column. Today `accounts.name` is populated at register-time from the user's input or a sensible default; the wizard lets the user correct it.
New columns:
- `team_size_bucket VARCHAR(20) NULL``'1-2' | '3-5' | '6-10' | '11-25' | '26+'`
- `primary_psa VARCHAR(20) NULL``'connectwise' | 'autotask' | 'halopsa' | 'none'`
No billing state on `accounts` — it lives on `subscriptions`.
#### `account_invites` — small additions
- `revoked_at TIMESTAMP WITH TIME ZONE NULL` — distinguishes revoked from used. Current model has only `used_at`; revoke (resend handler at `accounts.py:323`) currently deletes the row. Add `revoked_at` + change resend to soft-revoke for audit trail.
- (Optional) `email_sent_at TIMESTAMP WITH TIME ZONE NULL` — track that the invite email was actually sent (today, only resend sends; create does not).
`AccountInvite.is_used` and `is_valid` properties extend to consider `revoked_at`.
### Migrations
Single Alembic chain — manual revisions per Lesson 77. Multi-head heads on `main` (`070`, `c0f3a4b7e91d`, `024`) currently coexist; the new chain branches from the most recent and merges via `alembic upgrade heads` (plural).
1. `add_oauth_identities.py` — new table.
2. `users_password_hash_nullable.py` — alter to nullable.
3. `users_add_role_at_signup_and_onboarding_step.py` — add `role_at_signup` and `onboarding_step_completed` columns.
4. `accounts_add_wizard_columns.py` — add `team_size_bucket`, `primary_psa`. (`accounts.name` already exists; wizard writes to it.)
5. `account_invites_add_revoked_at_and_email_sent_at.py` — add columns.
6. `add_plan_billing.py` — new sibling table. Seeds Starter / Pro / Enterprise rows **with `stripe_product_id` / `stripe_*_price_id` left NULL**. Existing `plan_limits` rows already exist for `'free' / 'pro' / 'team'`; this migration aligns keys (`'starter' | 'pro' | 'enterprise'` if we rename, OR keep `'free' / 'pro' / 'team'` and treat `'free'` as the floor — open risk #14 captures the decision). Stripe IDs are populated **out-of-band** per environment via either the existing `/admin/plan-limits` PUT (extended to accept Stripe fields) or a one-off `python -m scripts.sync_stripe_plan_ids` admin command driven by env vars. **Migrations stay environment-agnostic** — they don't read live mode vs. test mode IDs.
7. `seed_pro_starter_feature_flags.py` — register feature keys (`psa_integration`, `escalation_mode`, `script_builder`, `analytics_dashboards`, `knowledge_flywheel`, `team_admin_full`, `monthly_sessions` quantitative, `seats` quantitative, `sso`, `audit_log`) in `feature_flags`; populate `plan_feature_defaults` per the Pro/Starter split.
8. `subscriptions_pilot_complimentary_backfill.py``UPDATE subscriptions SET status='complimentary', plan='pro' WHERE status NOT IN ('canceled')` for accounts that exist as of cutover. Single statement; ≤ 100 rows expected.
9. `add_sales_leads_and_stripe_events.py` — two new tables.
Forward-only. No down-migrations for the data backfills (step 8) — the original status values per account are not preserved.
### RLS notes
- `oauth_identities` is account-adjacent (joined via `user_id`), but RLS on `users` is admin-DB-only (per `deps.py` `get_current_user` uses `get_admin_db`). Treat `oauth_identities` the same — no per-tenant RLS policy; queries use admin session. Verify against current `users` table policy before merging.
- `plan_billing` is global (joins `plan_limits.plan`, also global). No RLS.
- `sales_leads`, `stripe_events` are global. No RLS.
- `account_invites` already has its policy (account-scoped). No change.
- `subscriptions` already has its policy. No change to schema means no RLS revision.
### Index notes
- `oauth_identities (provider, provider_subject)` UNIQUE — the OAuth callback's primary lookup.
- `oauth_identities (user_id)` — list a user's identities.
- `account_invites (revoked_at)` — partial filter for active-invites queries (`WHERE accepted_by_id IS NULL AND revoked_at IS NULL`).
---
## Section 3 — Funnel walkthrough
### 1. Acquisition — `/pricing` (public)
New route. B-style page: hero (one-liner + reverse-trial reassurance), three plan cards (Starter / Pro recommended / Enterprise), comparison table, testimonial slot (placeholder copy until a real one lands), trust strip ("SOC2 in progress · Stripe billing · GDPR DPA available"). Plan card data sourced from `plan_billing` filtered by `is_public=TRUE AND is_archived=FALSE`.
- **Pro/Starter cards** → "Start free trial" → `/register?plan=pro` (or `?plan=starter`). Query param remembered through OAuth round-trip.
- **Enterprise card** → "Talk to sales" → `/contact-sales` → POST `/sales-leads` → confirmation page with Calendly link in the email.
- Existing `LandingPage.tsx` gets a "See pricing" CTA pointing here.
### 2. Registration — `/register` (public, redesigned)
Three sign-up paths on one page:
- **Google sign-in** (primary button at top) → OAuth round-trip → `/auth/google/callback`. Backend creates a User if first time (`oauth_identities` row + Account + Subscription on Pro trial via `BillingService.start_trial`), marks `email_verified_at = now()` (provider-attested), redirects to `/welcome`.
- **Microsoft sign-in** (button) → same flow with `provider='microsoft'`.
- **Email + password** → POST `/auth/register`. Backend creates User (with `password_hash` set) + Account, calls `BillingService.start_trial`, sends verification email via existing `EmailService.send_email_verification_email` (auto-send is added; today the user has to click a button), returns JWT, frontend redirects to `/welcome`.
Form fields: full name, work email, password (10+ chars, complexity rules per existing `UserCreate.password_complexity` validator). The current `invite_code` field on `UserCreate` is **removed at the registration gate** — public signups don't need one. The `account_invite_code` field is **kept** for the teammate-accept flow (see step 5b below).
**Critical fix flagged in review:** registration with `account_invite_code` must enforce `user_data.email == account_invites.email` (today this is not enforced at `/auth/register`). The check happens in the register handler before the User is created; mismatch returns 400 with `{"error": "invite_email_mismatch"}`.
### 3. Welcome wizard — `/welcome` (authed)
Dedicated routes: `/welcome/step-1` (Your shop), `/welcome/step-2` (Your PSA), `/welcome/step-3` (Invite team). `/welcome` itself redirects to the lowest-numbered incomplete step. Each step persists immediately (PATCH endpoints — see Appendix A) so refreshes don't lose data and "Skip the rest" lands cleanly.
- **Step 1 — Your shop**: company name (pre-filled from existing `accounts.name`, editable), team size bucket, your role. Saves to `accounts.name`, `accounts.team_size_bucket`, `users.role_at_signup`.
- **Step 2 — Your PSA**: PSA selection only. Saves to `accounts.primary_psa`. Quiet "Connect now" link → `/account/integrations` (out of wizard); default action is **Continue**. No API key entry inside the wizard.
- **Step 3 — Invite your team**: up to 3 email fields visible with "+ Add another" link; each invite defaults to "Tech" role; fully skippable. POSTs to a new `POST /accounts/me/invites/bulk` (thin wrapper around the existing single-create) **and sends invite emails per row**. The wizard's "Tech" UI label maps to `account_invites.role = 'engineer'` in the DB; "Viewer" UI label maps to `'viewer'` (per the existing CHECK constraint).
**Critical fix flagged in review:** today, `POST /accounts/me/invites` (`accounts.py:257`) creates the row but does NOT send the email — only `/me/invites/{id}/resend` sends. The new flow wires `EmailService.send_account_invite_email` (existing method at `core/email.py:125`) into both create and bulk paths and stamps `email_sent_at` on success.
Skip behavior: "Skip" on a step advances `users.onboarding_step_completed` (recording that the user saw and chose to skip that step). A separate "Skip the rest, take me to dashboard" link sets `users.onboarding_dismissed=TRUE` and redirects to `/`. Wizard is "done" when `onboarding_dismissed=TRUE` OR `onboarding_step_completed >= 3`. Auth-store reads this state on app load; `/welcome` redirects to the next incomplete step or to `/` if done.
**Invited teammate variant:** invitee's email link goes to a frontend `/accept-invite?code=…` route that posts to `/auth/register` with `account_invite_code` (per the existing `UserCreate` schema). They land on `/?welcome=teammate` instead of the wizard, and get a brief "Welcome to {company}'s ResolutionFlow" toast. Re-running the wizard on already-onboarded users is suppressed via `users.onboarding_dismissed` OR derived data presence.
### 4. Dashboard — `/` (authed, redesigned)
- **Topbar pill** in `AppLayout` renders based on `subscriptions.status` and `current_period_end`:
- `trialing` AND `current_period_end > now()`: "Pro trial · Nd" — blue, amber when ≤3d remaining, red when ≤1d.
- `trialing` AND `current_period_end <= now()`: "Trial expired — pick a plan" (the locked state — no mutation has occurred at the DB level, just rendered differently).
- `active`: tier name only ("Pro" / "Starter") — no urgency.
- `complimentary`: "Complimentary Pro" — friendly tag, no CTA.
- `past_due`: "Payment failed — update card" — clickable, routes to `/account/billing`.
- `canceled`: pill becomes a "Reactivate" CTA.
- **Next-step card** sits below the topbar. "Show all setup steps" link expands the full 6-item list inline.
- **Email-verification banner** (when `users.email_verified_at IS NULL`): always-visible thin bar above the next-step card with a "Resend" link (POSTs to existing `/auth/email/send-verification`). On day 7 unverified, the dashboard route renders `<EmailVerificationWall />` instead of normal content.
Checklist items (same for everyone — no SOLO/TEAM split):
1. **Verify your email** — auto-completes on link click; hidden if signed up via OAuth.
2. **Set up your shop** — completes when `users.onboarding_step_completed >= 1`.
3. **Run your first FlowPilot session** — the wedge. Highlighted as the headline action when prior items are complete.
4. **Connect your PSA** — auto-completes when first PSA connection saved. Pre-fills the provider based on welcome wizard selection.
5. **Invite a teammate** — auto-completes when first invitation is sent.
6. **Pick a plan** — appears earlier with low emphasis; turns urgent at ≤3 days remaining in trial.
The stale `tried_ai_assistant` / "Check out the Script Builder" item is dropped entirely.
### 5. Email verification — existing endpoints, new gating
- `POST /auth/email/send-verification` (existing, `auth.py:621`) is auto-called by `/auth/register` — today the user has to click a button.
- `POST /auth/email/verify` (existing, `auth.py:662`) consumes the token and sets `users.email_verified_at`.
- The frontend `/verify-email?token=…` route calls the existing endpoint and shows a success or error state.
- New: a frontend gating layer (`<EmailVerificationGate />`) wraps the dashboard route. Day 1-6 unverified shows the soft banner; day 7+ unverified renders `<EmailVerificationWall />`.
- **Backend enforcement** via the new `require_verified_email_after_grace` dep (Section 4). The frontend wall is UX; the backend dep prevents direct API access by an unverified user past the 7-day grace. Mounted on every protected router; allowlists `/auth/*` (logout, verify, send-verification, password change), `/users/me`, and `/billing/*` so the user can still log out, verify, manage their profile, and convert to paid.
No new endpoints, no new column. One new backend dep.
### 6. Trial-end — Days 10-14
- **Day 10**: in-app modal once ("Your trial ends in 4 days. Pick a plan to keep going."). Fired by `useTrialBanner` hook reading from `useBillingStore` (which polls `GET /billing/state`); per-user dismiss recorded in localStorage. Email day 10 + day 13 (`EmailService.send_trial_ending`).
- **Day 14**: when `subscriptions.status='trialing'` AND `current_period_end < now()`, the dashboard route renders `<TrialEndedWall />` with the plan picker (Starter / Pro radio + seat count input). **No DB mutation occurs** — the lockout is computed at request time. Past sessions remain visible read-only for 30 days after `current_period_end` — computed at render time as `current_period_end + INTERVAL '30 days' < now()`. After that window, sessions are still in the database (no destructive action) but the dashboard hides them behind the wall until billing is added.
### 7. Plan picker → Stripe Checkout — `/account/billing/select-plan` (authed)
User picks Starter/Pro + seat count → POST `/billing/checkout-session` → backend calls `stripe.checkout.sessions.create` with:
- `customer_email` from User
- `line_items` (price_id from `plan_billing` × quantity = seats)
- `mode='subscription'`
- `subscription_data.trial_end = current_period_end` if still in trial (Stripe takes over the trial countdown)
- `success_url=/account/billing?success=1`, `cancel_url=/account/billing/select-plan`
Frontend redirects to Stripe-hosted Checkout. Stripe `checkout.session.completed` webhook → backend updates `subscriptions.status='active'`, sets `stripe_subscription_id`, `stripe_price_id`, refreshes `current_period_start/end` from the Stripe subscription, sets `seat_limit`. Idempotency via `stripe_events.id`.
Success URL renders dashboard with "Pro active 🎉" toast.
### 8. Past-due / dunning
Stripe `invoice.payment_failed` webhook → `subscriptions.status='past_due'`. Topbar pill flips to "Payment failed — update card" linking to `/account/billing`, which uses Stripe's Customer Portal for card updates and cancellation. Dashboard remains accessible during the dunning window (Stripe default: 4 retries over 3 weeks). Account locks via `require_active_subscription` only at `canceled`.
### 9. Sales lead — `/contact-sales` (public)
Form posts to `/sales-leads` → creates row + sends email to `sales@resolutionflow.com` + emits PostHog event. Confirmation page: "Thanks — we'll reach out within 1 business day. Want to skip ahead? [Calendly link]." The Calendly link is a config string, not a calendar integration in v1.
### 10. Owner transfer (existing — noted)
Owner transfer is supported via the existing `POST /accounts/me/transfer-ownership` (`accounts.py:150`). The pricing-page Enterprise tier and the Account → Team page in the redesigned dashboard surface this for owners who need to hand off the account. **Not flagged as out-of-scope risk** as it was in the prior draft.
---
## Section 4 — Billing substrate + Stripe integration
### `app.services.billing.BillingService`
Single billing module — not a polymorphic provider abstraction.
```python
class BillingService:
@staticmethod
async def start_trial(db, account: Account) -> Subscription:
"""Creates or updates the Subscription row for a new account.
Sets plan='pro', status='trialing', current_period_end=now()+14d.
Called from /auth/register (email path) and OAuth-callback flows.
No Stripe API call yet — Stripe Customer is created lazily at first
checkout."""
@staticmethod
async def create_checkout_session(db, account, plan, seats, billing_interval) -> str:
"""Returns the Stripe Checkout URL. Creates Stripe Customer if missing
(stores stripe_customer_id on the **Account** row — existing column at
accounts.stripe_customer_id), then builds checkout.sessions.create
with line_items, mode='subscription', subscription_data.trial_end if
still within local trial, success/cancel URLs. Subscription row is
updated by the webhook handler with stripe_subscription_id and
stripe_price_id once checkout completes."""
@staticmethod
async def apply_subscription_event(db, event_type: str, payload: dict) -> None:
"""Single entry point for every Stripe webhook that mutates subscription
state. Pure function of (event_type, payload) -> DB writes. Called from
the webhook handler after signature verification + idempotency check."""
@staticmethod
async def open_customer_portal(account) -> str:
"""Returns Stripe-hosted Customer Portal URL for card updates and
cancellation."""
@staticmethod
async def get_billing_state(db, account: Account) -> BillingStateResponse:
"""Returns the full billing snapshot for /billing/state — subscription
status, plan, plan_billing metadata, plan_limits values, and the
flattened effective feature flags (defaults overridden by
account_feature_overrides)."""
```
`account_id` is the canonical local key; Stripe is the canonical remote state; the webhook handler is the bridge.
### Replacing the trial auto-downgrade
The existing logic in `deps.py:81-129` mutates `subscriptions` on every request when a trial expires:
```python
# CURRENT (to be removed):
if subscription.status == "trialing" and subscription.current_period_end < now():
subscription.plan = "free"
subscription.status = "active"
subscription.current_period_end = None
subscription.current_period_start = None
await db.commit()
```
**Replace this entire block with no-op.** Trial expiry becomes a *computed* state. The data stays as `status='trialing'`, `current_period_end` in the past — readable, observable, idempotent. The new `require_active_subscription` dep enforces the lockout.
If we ever want an explicit `'expired'` status (for analytics observability), it can be added later without changing the semantic of "trialing + past current_period_end = locked."
### New backend dep — `require_active_subscription`
```python
_SUBSCRIPTION_GUARD_ALLOWLIST = {
# auth & profile
"/api/v1/auth/me",
"/api/v1/auth/logout",
"/api/v1/auth/password/change",
"/api/v1/auth/email/send-verification",
"/api/v1/auth/email/verify",
# billing surfaces
"/api/v1/billing/state",
"/api/v1/billing/checkout-session",
"/api/v1/billing/portal-session",
# users own profile
"/api/v1/users/me",
"/api/v1/users/me/onboarding-step",
# read-only history (pattern match: /sessions and /trees in GET only)
}
async def require_active_subscription(
request: Request,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_admin_db),
) -> Subscription:
"""Enforces 'this account currently has access.' Mounted on routers that
require Pro entitlement. Returns the Subscription row when allowed; raises
402 with structured payload when locked."""
if request.url.path in _SUBSCRIPTION_GUARD_ALLOWLIST:
return None # bypass
sub = await _get_subscription_for_account(db, current_user.account_id)
if not sub:
raise HTTPException(402, detail={"error": "no_subscription"})
is_live = (
sub.status in ("active", "complimentary")
or (
sub.status == "trialing"
and sub.current_period_end is not None
and sub.current_period_end > datetime.now(timezone.utc)
)
or sub.status == "past_due" # dunning grace — Stripe retries
)
if not is_live:
raise HTTPException(
status_code=402,
detail={
"error": "subscription_inactive",
"status": sub.status,
"plan": sub.plan,
"current_period_end": sub.current_period_end.isoformat() if sub.current_period_end else None,
"upgrade_url": "/account/billing/select-plan",
},
)
return sub
```
Mounted on every router under `/api/v1/` *except* the explicit allowlist. GET endpoints for past sessions/trees during the 30-day read-only post-expiry window need a softer variant — see Section 3 step 6 for the read-only contract. Implementation plan will identify each protected endpoint specifically.
### New backend dep — `require_verified_email_after_grace`
Mirror of `require_active_subscription`, but for email verification. The frontend `<EmailVerificationWall />` is a UX layer; this dep is the security layer that prevents an unverified user from bypassing the wall by hitting product APIs directly.
```python
_EMAIL_VERIFICATION_ALLOWLIST = {
# auth & session
"/api/v1/auth/me",
"/api/v1/auth/logout",
"/api/v1/auth/email/send-verification",
"/api/v1/auth/email/verify",
"/api/v1/auth/password/change",
# users own profile
"/api/v1/users/me",
# billing — let user manage subscription even if email unverified
"/api/v1/billing/state",
"/api/v1/billing/checkout-session",
"/api/v1/billing/portal-session",
}
VERIFICATION_GRACE_DAYS = 7
async def require_verified_email_after_grace(
request: Request,
current_user: User = Depends(get_current_active_user),
) -> None:
"""Enforces 'this user has verified their email, OR is still inside the
7-day grace from account creation.' OAuth signups bypass cleanly because
/auth/google/callback and /auth/microsoft/callback set
users.email_verified_at = now() (provider-attested).
Mounted on every protected router *except* the explicit allowlist."""
if request.url.path in _EMAIL_VERIFICATION_ALLOWLIST:
return
if current_user.email_verified_at is not None:
return
grace_ends = current_user.created_at + timedelta(days=VERIFICATION_GRACE_DAYS)
if datetime.now(timezone.utc) < grace_ends:
return # still inside grace
raise HTTPException(
status_code=403,
detail={
"error": "email_not_verified",
"grace_ended_at": grace_ends.isoformat(),
"resend_url": "/api/v1/auth/email/send-verification",
},
)
```
Differs from `require_active_subscription` in three ways:
- **403 (Forbidden) rather than 402 (Payment Required)** — verification is identity, not billing. Lets the frontend interceptor route to a verification CTA, distinct from the upgrade CTA.
- **No DB read** — uses fields already on the `current_user` row from `get_current_active_user`. Cheap.
- **Allowlist includes `/billing/*`** — an unverified user past day 7 should still be able to convert to paid (verification gates feature use, not billing). The verification banner persists into Checkout if needed.
The two guards compose: most routers depend on **both** `require_active_subscription` AND `require_verified_email_after_grace`. The implementation plan will identify each protected router specifically; both guards are non-optional for product surfaces.
### Stripe webhook handler — `POST /api/v1/webhooks/stripe`
A stub already exists at `app/api/endpoints/webhooks.py` with signature verification + an early-out when `settings.stripe_enabled=False`. This work extends the stub — does not replace it — by wiring concrete event handlers, idempotency tracking, and `BillingService.apply_subscription_event` integration.
- Public endpoint; signature verification is the only gate.
- Reads `Stripe-Signature` header → `stripe.Webhook.construct_event(payload, sig, STRIPE_WEBHOOK_SECRET)` → 400 on mismatch.
- **Idempotency**: every event recorded in `stripe_events` keyed by Stripe's event id. If the row exists, return 200 immediately.
- Uses `_admin_session_factory()` — no `current_account_id` is set during webhook processing (Phase 4 RLS pattern).
- **Replay protection**: Stripe signatures embed a timestamp; reject if older than 5 min.
Events handled:
| Event | Action |
|---|---|
| `checkout.session.completed` | Activate: `subscriptions.status='active'`, set `subscriptions.stripe_subscription_id`, `subscriptions.stripe_price_id`, `subscriptions.current_period_start/end`, `subscriptions.seat_limit` from session line_items. (`accounts.stripe_customer_id` was set earlier at `create_checkout_session` time.) |
| `customer.subscription.updated` | Reflect plan changes / period transitions / seat updates |
| `customer.subscription.deleted` | `status='canceled'`, lock via `require_active_subscription` |
| `invoice.payment_failed` | `status='past_due'` |
| `invoice.payment_succeeded` | Confirm `status='active'` after dunning recovery |
| Other | Log and ack 200 |
### Backend feature-gate dep — `require_feature`
Reads from the existing 3-table chain (no new tables). **`require_feature` internally composes with `require_active_subscription`** — feature gating without subscription gating would let canceled/expired-trial accounts pass feature checks. They are not independent.
```python
async def require_feature(flag_key: str):
async def _dep(
sub: Subscription = Depends(require_active_subscription),
user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_admin_db),
) -> None:
# require_active_subscription has already verified the account is live;
# sub is the live Subscription row. Now check the feature flag.
flag = await _resolve_flag(db, user.account_id, sub.plan, flag_key)
if not flag.enabled:
raise HTTPException(
status_code=402,
detail={
"error": "feature_not_in_plan",
"feature": flag_key,
"current_plan": sub.plan,
"upgrade_url": "/account/billing/select-plan",
},
)
return _dep
async def _resolve_flag(db, account_id, plan_key, flag_key):
"""Resolve effective feature flag value:
1. account_feature_overrides for (account_id, flag_key) -> if exists, use that
2. else plan_feature_defaults for (plan, flag_key) -> use that
3. else default disabled
"""
```
Used as `Depends(require_feature("psa_integration"))` on PSA endpoints, Escalation Mode, Script Builder, Analytics endpoints. The 402-with-payload pattern lets the frontend route the user to `/account/billing/select-plan`.
For quantitative limits (sessions per month, AI builds): existing `plan_limits` columns (`max_sessions_per_month`, `max_ai_builds_per_month`, etc.) already cover these. Use a sibling helper:
```python
async def require_within_limit(field: str):
"""e.g., field='max_sessions_per_month' — checks current usage against
the resolved plan_limits value, with account-override consulting via
/admin/plan-limits/account-overrides table."""
```
This is closer to the existing `get_user_plan_limits` helper (`core/subscriptions.py`) and reuses that path.
### Caching strategy
- Subscription row, plan_limits row, plan_billing row, and resolved feature flag map: cached in `app.state.billing_cache` keyed by `account_id`. TTL 5 minutes.
- Explicit invalidation triggers:
- Stripe webhook handler when `subscriptions` state changes (account-keyed invalidation).
- `/admin/plan-limits` PUT (invalidate **all** accounts on that plan, since plan-wide limits / billing fields changed).
- `/admin/plan-limits/account-overrides` POST/PUT/DELETE (account-keyed).
- `/admin/feature-flags` PUT/DELETE on flag definitions (full-cache flush).
- `/admin/feature-flags/plan-defaults` PUT (invalidate **all** accounts on that plan).
- `/admin/feature-flags/account-overrides` POST/DELETE (account-keyed).
- For Railway multi-worker: per-process cache. The 5-minute TTL bounds inconsistency. Acceptable for v1; revisit with Redis pubsub if we run > 2 workers.
### Frontend — `useBillingStore` + `GET /billing/state`
```
GET /billing/state -> {
subscription: {
status: 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'complimentary',
plan: 'starter' | 'pro' | 'enterprise',
current_period_start: ISODateTime | null,
current_period_end: ISODateTime | null,
cancel_at_period_end: boolean,
seat_limit: number | null,
has_pro_entitlement: boolean,
is_paid: boolean,
},
plan_billing: {
display_name: string,
monthly_price_cents: number | null,
annual_price_cents: number | null,
},
plan_limits: {
max_trees, max_sessions_per_month, max_users, ...all current PlanLimits fields
},
enabled_features: Record<string, boolean>, -- flat resolved map
}
```
Frontend hooks:
- `useFeature(key: string): boolean` — reads `enabled_features[key]` from `useBillingStore`.
- `useFeatureLimit(key): { used, limit, percentage, isAtLimit }` — combines `plan_limits[key]` with a lazy `/usage/{key}` count.
- `useTrialBanner(): { stage: 'pristine' | 'warning' | 'urgent' | 'expired', daysRemaining }` — derived from `subscription.status` + `current_period_end`.
- `<FeatureGate feature="psa_integration" fallback={<UpgradePrompt />}>...children</FeatureGate>` — wrapper for whole-section gating.
`useBillingStore` is a Zustand store with:
- Initial fetch on auth-store login.
- Refetch on webhook-driven server-sent events (or, for v1, polling every 60s while the dashboard is mounted).
- Manual `refetchBilling()` exposed for use after Stripe Checkout success-redirect.
`/auth/me` and `UserResponse` stay user-focused — no billing data embedded.
### Admin UI — reuse existing surfaces
- `/admin/plan-limits` — extended to also surface `plan_billing` fields in the editor (single PUT round-trips both tables in one transaction).
- `/admin/feature-flags` — unchanged. Toggling a flag's `plan_feature_defaults` enables/disables the feature for that plan tier.
- `/admin/feature-flags/account-overrides` — unchanged. Used for sales-negotiated grants, comp accounts, kill-switching a feature for one customer.
No new combined `/admin/plans` admin page in v1.
### Failure modes
| Scenario | Outcome |
|---|---|
| User abandons Stripe Checkout | No webhook fires; `subscriptions.status` stays `trialing`; trial-end wall fires normally on day 14 via `require_active_subscription` |
| Webhook arrives before app reconciles local state | `stripe_events` idempotency makes this safe |
| Webhook secret rotated | Old webhook attempts 400 until env var redeployed |
| Concurrent webhooks for the same subscription | DB row-level locks on the `subscriptions` row serialize updates; idempotency check is the first read in the transaction |
| Stripe outage during checkout | `BillingService.create_checkout_session` raises; frontend shows "Couldn't start checkout — try again" toast |
| Account on `complimentary` accidentally hits a webhook (e.g., admin manually attached a Stripe customer) | Handler transitions to whatever Stripe says; admin can revert via DB or via `/admin/plan-limits/account-overrides` if needed |
| OAuth-only user attempts `/auth/login` (password) | Login endpoint rejects with 400 `{"error": "use_oauth_provider", "providers": ["google"]}` so frontend can route them to the right button |
| OAuth-only user attempts `/auth/password/change` | Endpoint rejects with 400 — must set initial password via a separate `/auth/password/set-initial` flow (out of scope for v1; OAuth users stay OAuth-only) |
| OAuth-only user requests password reset | Reset email is suppressed; user is shown "Sign in with {provider}" instead |
---
## Section 5 — Migration plan
### Pre-deploy: Stripe configuration
Manual setup, separate per environment.
**Status note (2026-05-05):** Stripe **test mode** Products + Prices + webhook endpoint + test env vars in Railway are already configured. Live-mode setup remains for cutover.
For each environment:
1. **Stripe Dashboard**:
- Create Products: `ResolutionFlow Starter`, `ResolutionFlow Pro`, `ResolutionFlow Enterprise` (no public price).
- Create Prices for Starter/Pro: monthly + annual recurring.
- Enable **Customer Portal** with: update payment method, cancel subscription, view invoices. Disable plan-switching from the portal.
- Register webhook endpoint at `https://api.resolutionflow.com/api/v1/webhooks/stripe` with the events listed in Section 4. Save the signing secret.
2. **Railway env vars** (per environment):
- `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PUBLISHABLE_KEY` (frontend; needs `ARG`+`ENV` in `frontend/Dockerfile` per Lesson 60).
### Schema migration
Manual revisions per Lesson 77. New chain branches from the most recent of `main`'s heads (`070`, `c0f3a4b7e91d`, `024`) and merges via `alembic upgrade heads`. Migration filenames are listed in Section 2.
Forward-only.
### Pilot user transition
- Migration step 8 sets `subscriptions.status='complimentary'`, `plan='pro'` for all existing accounts (≤ 100 rows). Single statement.
- **Outbound communication**: a single email from `EmailService.send_complimentary_account_announcement` to every pilot user 1-2 days before cutover:
> *"We're opening ResolutionFlow up for new signups. Your account is now a Complimentary Pro account — nothing changes for you. You'll see a small "Complimentary Pro" tag in the app instead of any trial pill. Thanks for piloting."*
- **In-app first-login toast** (optional; ship without if scope tightens): per-browser via localStorage key `rf-complimentary-announcement-seen-{user_id}`.
### Existing invite-code disposition
- `invite_codes` table preserved.
- `User.invite_code_id` foreign keys preserved for historical pilots.
- Registration handler (`/auth/register`) drops the invite_code-required gate. The `UserCreate.invite_code` field stays in the schema for backward compatibility but is ignored at registration. No new validations against the `invite_codes` table at signup.
- No promo-code repurposing. Invite codes simply stop being consumed.
### Beta-signup deprecation
- `beta_signup.py` endpoint stays mounted but returns 307 redirect to `/register?from=beta`.
- Existing waitlist rows: send a "we've launched — come on in" email with a one-time `from=beta` link. Preserve the table; do not drop.
### Deploy ordering — dark launch then cutover
1. **Backend deploy with `SELF_SERVE_ENABLED=false`**: all new endpoints exist (webhook handler, billing, OAuth callbacks, sales-leads, bulk invite, billing/state). `/auth/register` retains the existing invite-code requirement. `/pricing` returns 404. Webhook handler is **live**.
2. **Frontend deploy with `VITE_SELF_SERVE_ENABLED=false`**: new surfaces are routed but hidden behind the flag.
3. **Stripe live-mode configuration in prod** (manual, ~30 min).
4. **Internal validation (1-2 days)**: founder + any teammates use a per-email allowlist to enable self-serve for their accounts only. Tests cover: email signup, OAuth signup paths, invitation accept (with email-match enforcement), pilot complimentary view, past-due simulation via Stripe test cards, subscription guard for locked accounts.
5. **Cutover**: flip `SELF_SERVE_ENABLED=true` and `VITE_SELF_SERVE_ENABLED=true` in prod. Pricing page goes live.
6. **Week 1 monitoring**: PostHog funnel; webhook logs; error rates.
### Rollback strategy
- Schema is forward-only — no down-migration for the backfills.
- Rollback = flag flip. `SELF_SERVE_ENABLED=false` reverts public surfaces; pilot users continue on `complimentary` status (benign — the existing schema supports it either way after step 8).
- New surfaces (pricing page, etc.) return 404 when the flag is off.
- Webhook handler stays live regardless.
### Risks worth flagging
| Risk | Mitigation |
|---|---|
| Pilot users confused by "Complimentary Pro" change | Pre-launch email + first-login toast |
| `is_paid` regression — paid metrics include comp accounts pre-fix | Audit `Subscription.is_paid` callers as part of step 1 of implementation; fix in same PR |
| Webhook misfires producing wrong subscription state | Idempotency table + alerting + Stripe webhook replay |
| Multi-head Alembic merge breaks in CI | Test `alembic upgrade heads` (plural) on a fresh DB before merging |
| Stripe Test vs. Live mode confusion | Distinct env vars per env; first prod transaction verified manually |
| OAuth callback `redirect_uri` drift across envs | Single `OAUTH_REDIRECT_BASE` env var; tested per env in validation |
| Email deliverability for verification + invitations + sales leads | Reuse existing `EmailService` pipeline; verify SPF/DKIM/DMARC alignment |
| Email-match enforcement at register breaks teammate accept if invitee mistypes their address | Clear error message; resend with corrected email is one click from the failure page |
| Subscription guard allowlist drift (a new endpoint added without thinking about lockout) | Add a CI test that exercises every router with a `canceled` subscription and verifies 402 unless explicitly allowlisted |
| Email-verification guard allowlist drift (a new endpoint added without thinking about unverified users past grace) | Same CI pattern — exercise every router with an unverified day-8 user and verify 403 unless explicitly allowlisted |
| Plan key rename (`free`/`pro`/`team``starter`/`pro`/`enterprise`) | Decision deferred to implementation plan; if rename, migration must update every reference in `subscriptions.plan` and `plan_limits.plan` |
---
## Section 6 — Testing, rollout, open risks
### Test strategy
#### Backend (`pytest`)
- **Unit tests** for `BillingService` methods. Stripe mocked via `respx`. Each method's happy path + at least one error path.
- **Webhook handler integration tests**: feed canned Stripe webhook payloads and assert resulting `subscriptions` state. One test per event type. **Idempotency test**: send the same event id twice, assert single state mutation.
- **`require_feature` integration tests**: parametrized over (plan, flag_key) pairs; test override resolution (`account_feature_overrides` beats `plan_feature_defaults`).
- **`require_active_subscription` integration tests**:
- Each `subscriptions.status` value × allowlisted/non-allowlisted route → expected 200 or 402.
- **Replaces and verifies the trial expiry change**: a `trialing` row with `current_period_end < now()` should NOT be mutated by the dep; the dep should return 402 on protected routes and 200 on allowlisted routes.
- "complimentary should not block protected routes" smoke test.
- **`require_verified_email_after_grace` integration tests**:
- Each combination of (verified, unverified-in-grace, unverified-past-grace) × (allowlisted, non-allowlisted route) → expected 200 or 403.
- OAuth-signup user has `email_verified_at` set at callback time → never blocked.
- User on day 6 unverified passes; user on day 8 unverified blocks; verifying mid-test transitions to passing.
- **Combined-guard test**: protected routers mounting both `require_active_subscription` and `require_verified_email_after_grace` reject an unverified expired-trial account with the appropriate error (whichever check fires first is acceptable; assert one of the two error payloads).
- **Subscription model property tests**: `is_active`, `is_paid`, `has_pro_entitlement` across every status × plan combination.
- **Auth integration tests**:
- `/auth/register` happy path + duplicate email + weak password + email-match enforcement when `account_invite_code` provided.
- `/auth/google/callback` and `/auth/microsoft/callback` with mocked OAuth provider responses.
- `/auth/email/send-verification` auto-fired by register.
- `/auth/email/verify` with valid / expired / already-used tokens (already covered; smoke regression).
- **OAuth-only user paths**: `/auth/login` rejects, `/auth/password/change` rejects, password reset suppressed.
- **Invitation tests**:
- `/accounts/me/invites` create now sends email (regression: today it doesn't).
- `/accounts/me/invites/bulk` creates N rows + sends N emails.
- Email-match enforcement at register.
- Expired/revoked token, idempotent re-accept.
- **Plan-limits + feature-flags admin tests**: existing tests stay; extend with a test that round-trips `plan_billing` fields through `/admin/plan-limits` PUT.
- **Anti-parrot guardrail**: existing `tests/test_prompt_anti_parrot.py` covers any new system prompts (verification email, invitation email, sales-lead intake) automatically.
- **Phase 4 RLS smoke test**: every new account-scoped endpoint exercised with a non-matching `app.current_account_id`.
#### Frontend (Vitest + Playwright)
- **Component tests** for `<TrialPill />` (each subscription status branch + trialing-expired computed branch), `<NextStepCard />`, `<EmailVerificationBanner />`, `<EmailVerificationWall />`, `<TrialEndedWall />`, `<FeatureGate />`, `<UpgradePrompt />`.
- **Hook tests** for `useFeature`, `useFeatureLimit`, `useTrialBanner`, `useBillingStore` (initial fetch, refetch on webhook event, refetch after Stripe Checkout success).
- **Playwright E2E**:
- Register → wizard step-by-step → dashboard.
- OAuth round-trip with mocked provider.
- Trial-end wall → plan picker → mock Stripe Checkout → activated state.
- Past-due banner via webhook simulation.
- Pilot complimentary view (no walls, no nudges, "Complimentary Pro" pill).
- Invitation accept (full flow with `account_invite_code` from a backend fixture; email-match success and failure paths).
#### Manual validation phase (1-2 days before cutover)
| Scenario | Method |
|---|---|
| Email signup → wizard → first session → trial-end synthetic time → Checkout → active | Real flow with Stripe test mode + a date-shimmed account |
| Google sign-in | Real Google account |
| Microsoft sign-in | Real Microsoft 365 account |
| Past-due simulation | Stripe test card `4000 0000 0000 0341` |
| Pilot complimentary banner + first-login toast | Log in as an existing pilot account post-deploy |
| Webhook signature mismatch handling | Send a forged webhook with bad signature, expect 400 + log entry |
| OAuth provider redirect_uri matches | Visual check on each environment's Google + Microsoft app config |
| `is_paid` audit | Query a known complimentary account: confirm `is_paid=False`, `has_pro_entitlement=True` |
### Rollout monitoring
#### PostHog event taxonomy
- **Funnel**: `pricing_page_viewed`, `register_started`, `register_completed` (with `method`), `email_verification_sent`, `email_verification_completed`.
- **Wizard**: `welcome_wizard_step_completed` (step number), `welcome_wizard_skipped` (`from_step`), `welcome_wizard_completed`.
- **Activation**: `first_session_started` (existing), `psa_connected`, `teammate_invited`, `teammate_accepted_invite`.
- **Trial conversion**: `trial_modal_shown`, `trial_modal_dismissed`, `trial_ended_wall_shown`, `plan_picker_viewed`, `checkout_session_created`, `checkout_completed`, `checkout_abandoned`.
- **Feature-gate signal**: `feature_gate_blocked` (with `feature_key` + `current_plan`).
- **Sales**: `talk_to_sales_form_submitted` (with `source`), `complimentary_account_first_view`.
#### Alerting
- Stripe webhook signature failures > 1/hour.
- Stripe API errors during checkout-session creation > 1/hour.
- OAuth callback failures > 5/hour.
- Email send failures (`EmailService` errors) on verification or invitation paths.
- Any 500 from `/webhooks/stripe`.
- 402 rate spike on non-allowlisted endpoints (could indicate guard misconfiguration).
#### Operational dashboards
- Daily: trial signups, completed checkouts, MRR delta (using corrected `is_paid`).
- Weekly: trial→paid conversion rate, OAuth-method mix, wizard skip rate per step.
- Per-feature: `feature_gate_blocked` count by `flag_key`.
### Stripe MCP tooling note
Once the Stripe MCP plugin loads in a future Claude Code session, it speeds up two things: **debugging webhook state** for support cases and **ad-hoc subscription mutations** (compt'ing accounts, fixing stuck states). Worth using post-launch for ad-hoc support; not load-bearing for the spec.
### Open risks and unknowns (carry-forward)
| # | Item | Status |
|---|---|---|
| 1 | **Pricing numbers** ($/seat/month for Starter and Pro) | Out of design scope. Set during validation phase. Schema supports any value via `plan_billing.monthly_price_cents` / `annual_price_cents`. |
| 2 | **Stripe Tax** | Disabled in v1. Revisit when first international signup arrives. |
| 3 | **Multi-account membership** (one user in multiple shops) | Out of scope. v1 is one user → one account. |
| 4 | **Owner transfer** | **Existing capability**`POST /accounts/me/transfer-ownership` (`accounts.py:150`). Surface in the redesigned Account → Team page. |
| 5 | **Annual billing UI** | Stripe Prices exist via `plan_billing.stripe_annual_price_id`, but the in-app picker only surfaces monthly in v1. Add later. |
| 6 | **SSO (SAML/OIDC) for Enterprise** | Promised on the pricing page Enterprise tier. Actual impl deferred until first paying Enterprise customer. Sales conversation must set expectations honestly. |
| 7 | **GDPR DPA template** | Trust strip claims "GDPR-ready DPA available." Founder/lawyer needs to produce the actual document — not eng work, but blocking the trust-strip claim being honest. |
| 7b | **SOC2 status** | Trust strip claims "SOC2 in progress." If the engagement isn't started by cutover, soften the trust-strip copy. |
| 8 | **Customer Portal cancellation customization** | Stripe-hosted Portal can't be customized. Acceptable for v1. |
| 9 | **Email deliverability** | First big surge may trip spam filters. Verify SPF/DKIM/DMARC alignment before cutover. |
| 10 | **Reverse-trial conversion math** | If trial→paid is bad post-launch, may need to flip to card-upfront. Schema supports it; policy decision based on data. Re-evaluate at week 4. |
| 11 | **Promo codes** | **Deferred from v1.** No `promo_codes` table. If a launch campaign needs them, add a separate table later with Stripe coupon semantics; do not retrofit `invite_codes`. |
| 12 | **Pricing page A/B testing** | Not in v1. PostHog has experiment tooling for A/B headlines later. |
| 13 | **OAuth-only password set-initial flow** | An OAuth-only user can't add a password later in v1. Out of scope; users who want a password can ask support to enable it manually. |
| 14 | **Plan key rename** | Existing `plan_limits` rows use `'free' / 'pro' / 'team'`. Public-facing tiers are Starter / Pro / Enterprise. Implementation plan decides whether to rename keys or maintain a display-name mapping in `plan_billing`. |
---
## Appendix A — Endpoint inventory
Categorized as **NEW**, **MODIFIED**, or **EXISTING (referenced)**.
### Public
| Status | Method | Path | Purpose |
|---|---|---|---|
| NEW (frontend route) | GET | `/pricing` | Public pricing page |
| NEW | POST | `/sales-leads` | Talk-to-sales form |
| NEW | GET/POST | `/auth/google/callback` | Google OAuth callback |
| NEW | GET/POST | `/auth/microsoft/callback` | Microsoft OAuth callback |
| EXISTING | POST | `/auth/email/send-verification` | (auto-called from register; today user-initiated) |
| EXISTING | POST | `/auth/email/verify` | Token consumption |
| MODIFIED | POST | `/auth/register` | Drops invite-code-required gate; calls `BillingService.start_trial()`; auto-sends verification email; **enforces email match against `account_invites.email` when `account_invite_code` is provided** |
| MODIFIED | POST | `/webhooks/stripe` | Stripe webhook handler. Stub exists at `app/api/endpoints/webhooks.py` (signature verification + early-out when `stripe_enabled=False`). This work fleshes out event handlers (`checkout.session.completed`, `customer.subscription.*`, `invoice.payment_*`), idempotency via `stripe_events`, and `BillingService.apply_subscription_event` integration. |
### Authenticated user
| Status | Method | Path | Purpose |
|---|---|---|---|
| EXISTING | GET | `/auth/me` | Stays user-focused — no billing data embedded |
| NEW | GET | `/billing/state` | Subscription + plan + plan_limits + resolved feature flags |
| NEW | POST | `/billing/checkout-session` | Create Stripe Checkout session |
| NEW | GET | `/billing/portal-session` | Create Stripe Customer Portal session |
| NEW | GET | `/usage/{flag_or_limit_key}` | Live usage count for quantitative limits |
| NEW | PATCH | `/users/me/onboarding-step` | Persist welcome wizard step state (writes `accounts.name`, `accounts.team_size_bucket`, `accounts.primary_psa`, `users.role_at_signup`) |
| EXISTING | POST | `/accounts/me/transfer-ownership` | Owner transfer (no change) |
| MODIFIED | POST | `/accounts/me/invites` | **Now sends invite email at create-time** (today only resend sends) |
| NEW | POST | `/accounts/me/invites/bulk` | Wraps single-create in a loop; sends email per row |
| EXISTING | POST | `/accounts/me/invites/{id}/resend` | (no change) |
| NEW | DELETE | `/accounts/me/invites/{id}` | Soft-revoke an invite by setting `revoked_at`. (No DELETE/revoke route exists today; only POST create, POST resend, GET list.) |
### Super-admin (existing — referenced)
| Status | Method | Path | Purpose |
|---|---|---|---|
| MODIFIED | GET | `/admin/plan-limits` | Response now includes `plan_billing` fields per row |
| MODIFIED | PUT | `/admin/plan-limits` | Accepts `plan_billing` fields in payload (single transaction) |
| EXISTING | GET/POST/PUT/DELETE | `/admin/plan-limits/account-overrides` | (no change) |
| EXISTING | GET/POST/PUT/DELETE | `/admin/feature-flags` | (no change) |
| EXISTING | PUT | `/admin/feature-flags/plan-defaults` | (no change) |
| EXISTING | GET/POST/DELETE | `/admin/feature-flags/account-overrides` | (no change) |
No new combined `/admin/plans` admin page in v1.
---
## Appendix B — Glossary
- **Reverse trial**: time-bounded full-access trial with no card required at signup; card requested before billing kicks in.
- **Sales-assist (E)**: dedicated path for Enterprise prospects via "Talk to sales" CTA → contact form → manual onboarding by founder/sales.
- **Wedge**: Escalation Mode — the magic-moment feature pilots are evaluated against (≥1.0 hour saved per week per pilot per kill-switch criteria).
- **Complimentary**: permanent, non-time-bounded `subscriptions.status='complimentary'` value for grandfathered pilot users. No nags, no walls, full Pro entitlement. Distinct from `trialing` in that it never expires; distinct from `active` in that it doesn't count toward paid/MRR metrics.
- **Has Pro entitlement**: a property derived from `(status, plan, current_period_end)` that answers "can this account access Pro features right now?" — true for paid Pro, complimentary Pro, and active trials. Used by `require_feature` and `require_active_subscription`.
- **Locked subscription**: computed state `(status='trialing' AND current_period_end < now())` OR `(status IN ('canceled', 'incomplete'))`. No mutation occurs; `require_active_subscription` raises 402 on protected routes.
- **Plan keys**: `plan_limits.plan` is the canonical key; `plan_billing` joins on it; `subscriptions.plan` is the per-account key. Public-facing tier names (Starter / Pro / Enterprise) are display labels via `plan_billing.display_name`.

View File

@@ -3,3 +3,26 @@ VITE_API_URL=http://localhost:8000
# Sentry error monitoring (optional in dev, required in production)
VITE_SENTRY_DSN=
# Stripe publishable key (same pk_test_/pk_live_ value as backend STRIPE_PUBLISHABLE_KEY).
# Vite bakes this at build time, so prod requires ARG+ENV in frontend/Dockerfile (Lesson 60).
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_
# OAuth client IDs — must match backend GOOGLE_CLIENT_ID / MS_CLIENT_ID.
# Public values; Vite bakes at build time so prod requires ARG+ENV in frontend/Dockerfile.
VITE_GOOGLE_CLIENT_ID=
VITE_MS_CLIENT_ID=
# Origin used to build OAuth redirect_uri (e.g. http://localhost:5173 or https://app.example.com).
# Must equal backend OAUTH_REDIRECT_BASE so callback paths align. If unset, the
# frontend falls back to window.location.origin at click time.
VITE_OAUTH_REDIRECT_BASE=
# Self-serve signup safety fallback used by useAppConfig when GET /config/public
# is unreachable. Authoritative value comes from backend SELF_SERVE_ENABLED.
VITE_SELF_SERVE_ENABLED=false
# Calendly link surfaced on the /contact-sales confirmation screen. When unset,
# the "Want to skip ahead?" block is hidden. Vite bakes at build time, so prod
# requires ARG+ENV in frontend/Dockerfile.
VITE_CALENDLY_URL=

View File

@@ -17,10 +17,22 @@ ARG VITE_API_URL
ARG VITE_SENTRY_DSN
ARG VITE_PUBLIC_POSTHOG_KEY
ARG VITE_PUBLIC_POSTHOG_HOST
ARG VITE_STRIPE_PUBLISHABLE_KEY
ARG VITE_GOOGLE_CLIENT_ID
ARG VITE_MS_CLIENT_ID
ARG VITE_OAUTH_REDIRECT_BASE
ARG VITE_SELF_SERVE_ENABLED
ARG VITE_CALENDLY_URL
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY
ENV VITE_PUBLIC_POSTHOG_HOST=$VITE_PUBLIC_POSTHOG_HOST
ENV VITE_STRIPE_PUBLISHABLE_KEY=$VITE_STRIPE_PUBLISHABLE_KEY
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
ENV VITE_MS_CLIENT_ID=$VITE_MS_CLIENT_ID
ENV VITE_OAUTH_REDIRECT_BASE=$VITE_OAUTH_REDIRECT_BASE
ENV VITE_SELF_SERVE_ENABLED=$VITE_SELF_SERVE_ENABLED
ENV VITE_CALENDLY_URL=$VITE_CALENDLY_URL
# Build the application
RUN npm run build

View File

@@ -30,7 +30,7 @@ test.describe('authenticated navigation smoke tests', () => {
await page.goto('/account')
await expect(
page.getByRole('heading', { name: 'Account Management' }),
page.getByRole('heading', { name: 'Settings' }),
).toBeVisible()
})
})

View File

@@ -16,7 +16,7 @@ function App() {
} else {
setLoading(false)
}
}, [])
}, [fetchUser, isAuthenticated, setLoading])
return <RouterProvider router={router} />
}

View File

@@ -1,6 +1,22 @@
import apiClient from './client'
import type { Account, SubscriptionDetails, AccountMember, AccountInvite } from '@/types'
export interface BulkInviteRow {
email: string
role: 'engineer' | 'viewer'
expires_in_days?: number
}
export interface BulkInviteFailure {
email: string
error: string
}
export interface BulkInviteResponse {
created: AccountInvite[]
failed: BulkInviteFailure[]
}
export const accountsApi = {
async getMyAccount(): Promise<Account> {
const response = await apiClient.get<Account>('/accounts/me')
@@ -39,6 +55,18 @@ export const accountsApi = {
return response.data
},
/**
* Create multiple invites in one call (used by the welcome wizard step 3).
* Per-row failures land in `failed[]`; successes in `created[]`.
*/
async bulkInvite(invites: BulkInviteRow[]): Promise<BulkInviteResponse> {
const response = await apiClient.post<BulkInviteResponse>(
'/accounts/me/invites/bulk',
{ invites },
)
return response.data
},
async getInvites(): Promise<AccountInvite[]> {
const response = await apiClient.get<AccountInvite[]>('/accounts/me/invites')
return response.data

View File

@@ -1,6 +1,13 @@
import apiClient from './client'
import type { Token, User, UserCreate, UserLogin, UserUpdate } from '@/types'
export interface OAuthCallbackResponse {
access_token: string
refresh_token: string
token_type: string
is_new_user: boolean
}
export const authApi = {
async register(data: UserCreate): Promise<User> {
const response = await apiClient.post<User>('/auth/register', data)
@@ -71,6 +78,36 @@ export const authApi = {
async verifyEmail(token: string): Promise<void> {
await apiClient.post('/auth/email/verify', { token })
},
async googleCallback(
code: string,
options?: { accountInviteCode?: string; invitedEmail?: string },
): Promise<OAuthCallbackResponse> {
const response = await apiClient.post<OAuthCallbackResponse>(
'/auth/google/callback',
{
code,
account_invite_code: options?.accountInviteCode,
invited_email: options?.invitedEmail,
},
)
return response.data
},
async microsoftCallback(
code: string,
options?: { accountInviteCode?: string; invitedEmail?: string },
): Promise<OAuthCallbackResponse> {
const response = await apiClient.post<OAuthCallbackResponse>(
'/auth/microsoft/callback',
{
code,
account_invite_code: options?.accountInviteCode,
invited_email: options?.invitedEmail,
},
)
return response.data
},
}
export default authApi

View File

@@ -0,0 +1,79 @@
import { AxiosError } from 'axios'
import apiClient from './client'
import {
BillingPortalError,
type BillingPortalErrorCode,
type BillingPortalSessionResponse,
type BillingStateApiResponse,
type BillingStatePayload,
type CheckoutSessionRequest,
type CheckoutSessionResponse,
} from '@/types/billing'
/**
* Single boundary where the snake_case backend payload is transformed
* into the camelCase shape used by the rest of the frontend.
*
* Keeping the transform here means the store, hooks, and components
* never see snake_case keys.
*/
function transformBillingState(raw: BillingStateApiResponse): BillingStatePayload {
return {
subscription: raw.subscription ?? null,
planBilling: raw.plan_billing ?? null,
planLimits: raw.plan_limits ?? {},
enabledFeatures: raw.enabled_features ?? {},
}
}
export const billingApi = {
async getState(): Promise<BillingStatePayload> {
const response = await apiClient.get<BillingStateApiResponse>('/billing/state')
return transformBillingState(response.data)
},
/**
* Request a Stripe Customer Portal session URL for the active account.
*
* Throws a typed `BillingPortalError` when:
* - HTTP 503 → `stripe_not_configured` (server-side Stripe is disabled)
* - HTTP 400 + `error: 'no_stripe_customer'` → account hasn't been billed yet
*
* Other errors (5xx, network) propagate as the underlying AxiosError.
*/
async getPortalSession(): Promise<BillingPortalSessionResponse> {
try {
const response = await apiClient.get<BillingPortalSessionResponse>(
'/billing/portal-session',
)
return response.data
} catch (err) {
if (err instanceof AxiosError && err.response) {
const { status, data } = err.response
const code: BillingPortalErrorCode | null =
status === 503
? 'stripe_not_configured'
: status === 400 && data?.detail?.error === 'no_stripe_customer'
? 'no_stripe_customer'
: null
if (code) {
throw new BillingPortalError(code)
}
}
throw err
}
},
async createCheckoutSession(
payload: CheckoutSessionRequest,
): Promise<CheckoutSessionResponse> {
const response = await apiClient.post<CheckoutSessionResponse>(
'/billing/checkout-session',
payload,
)
return response.data
},
}
export default billingApi

Some files were not shown because too many files have changed in this diff Show More