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