feat(billing): plan taxonomy reconciliation + Stripe sync + internal-tester allowlist #164

Merged
chihlasm merged 6 commits from feat/billing-plan-taxonomy into main 2026-05-11 05:07:08 +00:00
Owner

Summary

Three commits unblocking Phase O of the self-serve signup cutover. PR #162 left two real gaps: the marketing surface used a different plan taxonomy than the backend, and Phase O Task 46 needed a per-email allowlist that was never built. This PR closes both.

Commits

  1. feat(billing): reconcile plan taxonomy and add Stripe sync script
    • Migration 4ce3e594cb87: rename teamenterprise in plan_limits, add starter row (caps interpolated between free and pro), defensive update of any existing subscriptions on the team slug.
    • Code rename across schemas (invite_code, billing, admin), Subscription model paid-plan checks, admin endpoints, and frontend useSubscription. Resource visibility (Tree.visibility='team', StepLibrary.visibility='team') is a separate domain and intentionally untouched.
    • New backend/scripts/sync_stripe_plan_ids.py: idempotent upsert of plan_billing rows from Stripe products by exact name match. Picks the active monthly recurring price for tiers that have one. Annual fields stay NULL by design — annual pricing is intentionally out of scope for now. Works against both test and live keys.
  2. chore(dev): pass STRIPE_* env to backend container; add repo-root .env.example
    • Wires STRIPE_SECRET_KEY / STRIPE_PUBLISHABLE_KEY / STRIPE_WEBHOOK_SECRET / SELF_SERVE_ENABLED / INTERNAL_TESTER_EMAILS through docker-compose.dev.yml.
    • New .env.example at the repo root documenting the variables compose itself reads (separate from backend/.env.example).
  3. feat(billing): add INTERNAL_TESTER_EMAILS allowlist for self-serve soft cutover
    • Settings.is_internal_tester and Settings.is_self_serve_active_for centralize the allowlist + global-flag check.
    • New get_current_user_optional dep — best-effort auth that returns None instead of 401 — used by /config/public.
    • /config/public now honors the allowlist for authenticated callers; anonymous calls always see the global flag.
    • /auth/register swaps the SELF_SERVE_ENABLED check for is_self_serve_active_for(user_data.email) so allowlisted emails register without an invite code.

Stripe-side state (test mode, manual via MCP)

  • Archived the leftover Enterprise $500/mo test price (Enterprise is sales-led, no catalog price by spec).
  • Ran sync_stripe_plan_ids.py against test mode — plan_billing populated for all three tiers in dev DB.

Test plan

  • pytest tests/test_billing_checkout.py tests/test_plans_public.py tests/test_admin_plan_limits.py tests/test_invite_plan.py tests/test_config_public.py tests/test_auth.py — 42/42 passing
  • Broader sweep: 86/86 across subscription/billing/plan/invite/admin
  • python -m scripts.sync_stripe_plan_ids --dry-run then live run against test mode — plan_billing correctly populated
  • Migration up + down round-trip (verified via dev DB)
  • After merge: live-mode setup in Stripe Dashboard (manual), Railway prod env vars, run sync against prod, walk Phase O Task 46 scenarios with internal testers

Phase O remaining (manual ops)

  1. Live-mode Stripe Dashboard setup (Products + Prices for Starter/Pro, no Prices on Enterprise, Customer Portal config, webhook endpoint with 5 events)
  2. Railway prod env vars (sk_live_*, whsec_*, INTERNAL_TESTER_EMAILS, prod OAuth credentials, OAUTH_REDIRECT_BASE)
  3. Run sync against prod; verify plan_billing has sk_live_* price IDs
  4. Internal validation pass (9 scenarios from plan doc)
  5. Email pilots, flip SELF_SERVE_ENABLED=true (frontend redeploy required for VITE_SELF_SERVE_ENABLED)
  6. PostHog dashboards + Sentry alert at >1/hour webhook errors

🤖 Generated with Claude Code

## Summary Three commits unblocking Phase O of the self-serve signup cutover. PR #162 left two real gaps: the marketing surface used a different plan taxonomy than the backend, and Phase O Task 46 needed a per-email allowlist that was never built. This PR closes both. ### Commits 1. `feat(billing): reconcile plan taxonomy and add Stripe sync script` - Migration `4ce3e594cb87`: rename `team` → `enterprise` in `plan_limits`, add `starter` row (caps interpolated between free and pro), defensive update of any existing subscriptions on the `team` slug. - Code rename across schemas (`invite_code`, `billing`, `admin`), `Subscription` model paid-plan checks, admin endpoints, and frontend `useSubscription`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched. - New `backend/scripts/sync_stripe_plan_ids.py`: idempotent upsert of `plan_billing` rows from Stripe products by exact name match. Picks the active monthly recurring price for tiers that have one. Annual fields stay NULL by design — annual pricing is intentionally out of scope for now. Works against both test and live keys. 2. `chore(dev): pass STRIPE_* env to backend container; add repo-root .env.example` - Wires `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` / `STRIPE_WEBHOOK_SECRET` / `SELF_SERVE_ENABLED` / `INTERNAL_TESTER_EMAILS` through `docker-compose.dev.yml`. - New `.env.example` at the repo root documenting the variables compose itself reads (separate from `backend/.env.example`). 3. `feat(billing): add INTERNAL_TESTER_EMAILS allowlist for self-serve soft cutover` - `Settings.is_internal_tester` and `Settings.is_self_serve_active_for` centralize the allowlist + global-flag check. - New `get_current_user_optional` dep — best-effort auth that returns `None` instead of 401 — used by `/config/public`. - `/config/public` now honors the allowlist for authenticated callers; anonymous calls always see the global flag. - `/auth/register` swaps the `SELF_SERVE_ENABLED` check for `is_self_serve_active_for(user_data.email)` so allowlisted emails register without an invite code. ### Stripe-side state (test mode, manual via MCP) - Archived the leftover Enterprise `$500/mo` test price (Enterprise is sales-led, no catalog price by spec). - Ran `sync_stripe_plan_ids.py` against test mode — `plan_billing` populated for all three tiers in dev DB. ## Test plan - [x] `pytest tests/test_billing_checkout.py tests/test_plans_public.py tests/test_admin_plan_limits.py tests/test_invite_plan.py tests/test_config_public.py tests/test_auth.py` — 42/42 passing - [x] Broader sweep: 86/86 across subscription/billing/plan/invite/admin - [x] `python -m scripts.sync_stripe_plan_ids --dry-run` then live run against test mode — `plan_billing` correctly populated - [x] Migration up + down round-trip (verified via dev DB) - [ ] After merge: live-mode setup in Stripe Dashboard (manual), Railway prod env vars, run sync against prod, walk Phase O Task 46 scenarios with internal testers ## Phase O remaining (manual ops) 1. Live-mode Stripe Dashboard setup (Products + Prices for Starter/Pro, no Prices on Enterprise, Customer Portal config, webhook endpoint with 5 events) 2. Railway prod env vars (`sk_live_*`, `whsec_*`, `INTERNAL_TESTER_EMAILS`, prod OAuth credentials, `OAUTH_REDIRECT_BASE`) 3. Run sync against prod; verify `plan_billing` has `sk_live_*` price IDs 4. Internal validation pass (9 scenarios from plan doc) 5. Email pilots, flip `SELF_SERVE_ENABLED=true` (frontend redeploy required for `VITE_SELF_SERVE_ENABLED`) 6. PostHog dashboards + Sentry alert at >1/hour webhook errors 🤖 Generated with [Claude Code](https://claude.com/claude-code)
chihlasm added 3 commits 2026-05-07 23:48:03 +00:00
The marketing surface (PricingPage, Stripe products) was wired for
"Starter / Pro / Enterprise" while the backend was on "free / pro / team",
leaving plan_billing unseeded and BillingPlan accepting a literal that
violated the FK to plan_limits.

This change:

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The backend container had no Stripe env vars wired through compose, so
sync_stripe_plan_ids.py and any in-app Stripe calls would short-circuit
even when sk_test_ was set in the repo .env. Adds STRIPE_SECRET_KEY,
STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET pass-throughs.

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

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

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

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

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

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

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

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

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

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

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

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

Verified: tsc -b clean, lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chihlasm added 1 commit 2026-05-11 03:41:49 +00:00
wip(handoff): pr #164 cutover blockers + doc refresh + dns triage
All checks were successful
Mirror to GitHub / mirror (push) Successful in 6s
CI / frontend (pull_request) Successful in 6m44s
CI / e2e (pull_request) Successful in 10m12s
CI / backend (pull_request) Successful in 10m33s
25d124c6eb
Updates HANDOFF.md, CURRENT_TASK.md, SESSION_LOG.md to reflect this
session's work: PR #164 ready for review (5 commits closing the last
self-serve cutover code blockers), Phase O manual-ops sequence as the
resume point, and the apex-DNS / Edge-HSTS issues open on the user's
side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chihlasm merged commit 3f04911070 into main 2026-05-11 05:07:08 +00:00
Sign in to join this conversation.