From ba36c4707551856ecaf4b80248213a8a821d5e20 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 7 May 2026 15:59:21 -0400 Subject: [PATCH 1/6] feat(billing): reconcile plan taxonomy and add Stripe sync script 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) --- ...7_add_starter_rename_team_to_enterprise.py | 84 ++++++++ backend/app/api/endpoints/admin.py | 4 +- backend/app/api/endpoints/admin_dashboard.py | 2 +- backend/app/models/subscription.py | 4 +- backend/app/schemas/admin.py | 2 +- backend/app/schemas/billing.py | 2 +- backend/app/schemas/invite_code.py | 2 +- backend/app/schemas/subscription.py | 2 +- backend/scripts/sync_stripe_plan_ids.py | 199 ++++++++++++++++++ backend/tests/conftest.py | 3 +- backend/tests/test_admin_plan_limits.py | 18 +- backend/tests/test_invite_plan.py | 6 +- backend/tests/test_plans_public.py | 11 +- frontend/src/hooks/useSubscription.ts | 2 +- 14 files changed, 316 insertions(+), 25 deletions(-) create mode 100644 backend/alembic/versions/4ce3e594cb87_add_starter_rename_team_to_enterprise.py create mode 100644 backend/scripts/sync_stripe_plan_ids.py diff --git a/backend/alembic/versions/4ce3e594cb87_add_starter_rename_team_to_enterprise.py b/backend/alembic/versions/4ce3e594cb87_add_starter_rename_team_to_enterprise.py new file mode 100644 index 00000000..e468bd0f --- /dev/null +++ b/backend/alembic/versions/4ce3e594cb87_add_starter_rename_team_to_enterprise.py @@ -0,0 +1,84 @@ +"""add_starter_rename_team_to_enterprise + +Revision ID: 4ce3e594cb87 +Revises: c6cbfc534fad +Create Date: 2026-05-07 19:36:27.172082 + +Plan tier taxonomy reconciliation. Marketing surface and Stripe products +named "Starter / Pro / Enterprise"; backend was on "free / pro / team". +This migration: + + 1. Defensively migrates any existing subscriptions on plan='team' to + plan='enterprise' (dev has zero such rows; prod is expected to have + none, but the UPDATE is safe and idempotent). + 2. Renames the plan_limits row 'team' -> 'enterprise'. plan_billing + and plan_feature_defaults are FK-referenced but currently empty; + the rename works because PostgreSQL allows updating PK values when + no FK rows reference them. + 3. Inserts a new plan_limits row for 'starter' between free and pro. + +Resource visibility (Tree.visibility, StepLibrary.visibility) also uses +the string 'team' for "shared with my account" — that is a separate +domain and is intentionally not touched. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '4ce3e594cb87' +down_revision: Union[str, None] = 'c6cbfc534fad' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("UPDATE subscriptions SET plan = 'enterprise' WHERE plan = 'team'") + op.execute("UPDATE plan_limits SET plan = 'enterprise' WHERE plan = 'team'") + op.execute(""" + INSERT INTO plan_limits ( + plan, + max_trees, + max_sessions_per_month, + max_users, + custom_branding, + priority_support, + export_formats, + max_ai_builds_per_month, + max_ai_builds_per_24h, + kb_accelerator_enabled, + kb_max_lifetime_conversions, + kb_batch_max_size, + kb_allowed_formats, + kb_detailed_analysis, + kb_conversational_refinement, + kb_step_library_matching, + kb_history_limit + ) VALUES ( + 'starter', + 10, + 75, + 1, + FALSE, + FALSE, + '["markdown", "text", "html"]'::jsonb, + 15, + 5, + FALSE, + NULL, + NULL, + '["txt", "paste", "md"]'::jsonb, + FALSE, + FALSE, + FALSE, + NULL + ) + ON CONFLICT (plan) DO NOTHING + """) + + +def downgrade() -> None: + op.execute("DELETE FROM plan_limits WHERE plan = 'starter'") + op.execute("UPDATE plan_limits SET plan = 'team' WHERE plan = 'enterprise'") + op.execute("UPDATE subscriptions SET plan = 'team' WHERE plan = 'enterprise'") diff --git a/backend/app/api/endpoints/admin.py b/backend/app/api/endpoints/admin.py index ae606fb5..64033edf 100644 --- a/backend/app/api/endpoints/admin.py +++ b/backend/app/api/endpoints/admin.py @@ -972,7 +972,7 @@ async def update_user_plan( current_user: Annotated[User, Depends(require_admin)], ): """Change a user's subscription plan (super admin only).""" - if data.plan not in ("free", "pro", "team"): + if data.plan not in ("free", "pro", "starter", "enterprise"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan") user, subscription = await _get_user_subscription(user_id, db) old_plan = subscription.plan @@ -991,7 +991,7 @@ async def update_account_plan( current_user: Annotated[User, Depends(require_admin)], ): """Change an account subscription plan (super admin only).""" - if data.plan not in ("free", "pro", "team"): + if data.plan not in ("free", "pro", "starter", "enterprise"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan") account, subscription = await _get_account_subscription(account_id, db) old_plan = subscription.plan diff --git a/backend/app/api/endpoints/admin_dashboard.py b/backend/app/api/endpoints/admin_dashboard.py index 90859b18..0712558c 100644 --- a/backend/app/api/endpoints/admin_dashboard.py +++ b/backend/app/api/endpoints/admin_dashboard.py @@ -28,7 +28,7 @@ async def get_dashboard_metrics( ) or 0 paid_accounts = await db.scalar( select(func.count()).select_from(Subscription).where( - Subscription.plan.in_(["pro", "team"]) + Subscription.plan.in_(["pro", "starter", "enterprise"]) ) ) or 0 total_trees = await db.scalar( diff --git a/backend/app/models/subscription.py b/backend/app/models/subscription.py index 11024582..b4fa284e 100644 --- a/backend/app/models/subscription.py +++ b/backend/app/models/subscription.py @@ -37,12 +37,12 @@ class Subscription(Base): @property def is_paid(self) -> bool: # Excludes complimentary and trialing so MRR/paid-customer metrics aren't inflated. - return self.plan in ("pro", "team") and self.status not in ("complimentary", "trialing") + return self.plan in ("pro", "starter", "enterprise") and self.status not in ("complimentary", "trialing") @property def has_pro_entitlement(self) -> bool: """True if the account can access Pro features right now.""" - if self.plan in ("pro", "team"): + if self.plan in ("pro", "starter", "enterprise"): if self.status in ("active", "complimentary"): return True if self.status == "trialing" and self.current_period_end is not None: diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index a223d994..5d28670f 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -125,7 +125,7 @@ class AdminAccountDetailResponse(AdminAccountListItem): class AdminAccountCreate(BaseModel): name: str = Field(..., min_length=1, max_length=255) - plan: Literal["free", "pro", "team"] = "free" + plan: Literal["free", "pro", "starter", "enterprise"] = "free" owner_email: Optional[EmailStr] = Field(None, description="Email of an existing user to set as owner") diff --git a/backend/app/schemas/billing.py b/backend/app/schemas/billing.py index aaae78e6..48709c65 100644 --- a/backend/app/schemas/billing.py +++ b/backend/app/schemas/billing.py @@ -4,7 +4,7 @@ from pydantic import BaseModel class CheckoutSessionCreate(BaseModel): - plan: Literal["pro", "starter", "team", "enterprise"] + plan: Literal["pro", "starter", "enterprise"] seats: int billing_interval: Literal["monthly", "annual"] = "monthly" diff --git a/backend/app/schemas/invite_code.py b/backend/app/schemas/invite_code.py index 851403c8..6a917b6e 100644 --- a/backend/app/schemas/invite_code.py +++ b/backend/app/schemas/invite_code.py @@ -9,7 +9,7 @@ class InviteCodeCreate(BaseModel): expires_at: Optional[datetime] = Field(None, description="Optional expiration time") note: Optional[str] = Field(None, max_length=255, description="Note about who this code is for") email: Optional[EmailStr] = Field(None, description="Recipient email for invite delivery") - assigned_plan: Literal["free", "pro", "team"] = Field("free", description="Plan to assign on registration") + assigned_plan: Literal["free", "pro", "starter", "enterprise"] = Field("free", description="Plan to assign on registration") trial_duration_days: Optional[int] = Field(None, ge=1, le=90, description="Trial duration in days (1-90)") @model_validator(mode="after") diff --git a/backend/app/schemas/subscription.py b/backend/app/schemas/subscription.py index 80889fa0..e8f85385 100644 --- a/backend/app/schemas/subscription.py +++ b/backend/app/schemas/subscription.py @@ -41,7 +41,7 @@ class SubscriptionDetails(BaseModel): class SubscriptionPlanUpdate(BaseModel): - plan: str # free, pro, team + plan: str # free, pro, starter, enterprise model_config = {"json_schema_extra": {"examples": [{"plan": "pro"}]}} diff --git a/backend/scripts/sync_stripe_plan_ids.py b/backend/scripts/sync_stripe_plan_ids.py new file mode 100644 index 00000000..257f6a38 --- /dev/null +++ b/backend/scripts/sync_stripe_plan_ids.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Sync plan_billing rows from Stripe products and prices. + +Reads the active Stripe environment (test or live, determined by +STRIPE_SECRET_KEY in env), looks up the canonical ResolutionFlow products +by exact name match, picks the active monthly recurring price for tiers +that have one, and upserts plan_billing rows. + +Idempotent. Safe to re-run after price changes, after live cutover, or +after rotating Stripe keys. + +Tier mapping (name in Stripe -> plan slug in plan_limits): + ResolutionFlow Starter -> starter (monthly price required) + ResolutionFlow Pro -> pro (monthly price required) + ResolutionFlow Enterprise -> enterprise (no price, sales-led) + +Annual prices are intentionally not supported in this iteration. The +plan_billing schema allows annual fields (stripe_annual_price_id, +annual_price_cents); this script leaves them NULL. + +Usage: + docker exec -w /app resolutionflow_backend python -m scripts.sync_stripe_plan_ids + docker exec -w /app resolutionflow_backend python -m scripts.sync_stripe_plan_ids --dry-run +""" +import argparse +import asyncio +import logging +import sys +from typing import Optional + +import stripe + +from app.core.config import settings +from app.core.database import async_session_maker +from sqlalchemy import text + + +logger = logging.getLogger("sync_stripe_plan_ids") +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", +) + + +PLAN_NAME_TO_SLUG = { + "ResolutionFlow Starter": "starter", + "ResolutionFlow Pro": "pro", + "ResolutionFlow Enterprise": "enterprise", +} + +PLANS_REQUIRING_PRICE = {"starter", "pro"} + +PLAN_DEFAULTS = { + "starter": {"sort_order": 10, "is_public": True}, + "pro": {"sort_order": 20, "is_public": True}, + "enterprise": {"sort_order": 30, "is_public": True}, +} + + +def find_product_by_name(target: str) -> Optional[stripe.Product]: + """Page through active products and return the first exact name match.""" + for product in stripe.Product.list(active=True, limit=100).auto_paging_iter(): + if product.name == target: + return product + return None + + +def find_active_monthly_price(product_id: str) -> Optional[stripe.Price]: + """Return the active recurring monthly price for a product, or None.""" + candidates = [ + p + for p in stripe.Price.list(product=product_id, active=True, limit=100).auto_paging_iter() + if p.type == "recurring" + and p.recurring is not None + and p.recurring.get("interval") == "month" + and p.recurring.get("interval_count", 1) == 1 + ] + if not candidates: + return None + if len(candidates) > 1: + logger.warning( + "Product %s has %d active monthly recurring prices; picking %s. " + "Archive the others to silence this warning.", + product_id, len(candidates), candidates[0].id, + ) + return candidates[0] + + +async def upsert_plan_billing( + plan: str, + display_name: str, + description: Optional[str], + monthly_price_cents: Optional[int], + stripe_product_id: Optional[str], + stripe_monthly_price_id: Optional[str], + sort_order: int, + is_public: bool, + dry_run: bool, +) -> None: + """Upsert one plan_billing row. Annual fields stay NULL.""" + if dry_run: + logger.info( + "[dry-run] would upsert plan=%s display=%s monthly_cents=%s " + "product=%s monthly_price=%s", + plan, display_name, monthly_price_cents, + stripe_product_id, stripe_monthly_price_id, + ) + return + + sql = text(""" + INSERT INTO plan_billing ( + plan, display_name, description, + monthly_price_cents, annual_price_cents, + stripe_product_id, stripe_monthly_price_id, stripe_annual_price_id, + is_public, is_archived, sort_order + ) VALUES ( + :plan, :display_name, :description, + :monthly_price_cents, NULL, + :stripe_product_id, :stripe_monthly_price_id, NULL, + :is_public, FALSE, :sort_order + ) + ON CONFLICT (plan) DO UPDATE SET + display_name = EXCLUDED.display_name, + description = EXCLUDED.description, + monthly_price_cents = EXCLUDED.monthly_price_cents, + stripe_product_id = EXCLUDED.stripe_product_id, + stripe_monthly_price_id = EXCLUDED.stripe_monthly_price_id, + is_public = EXCLUDED.is_public, + sort_order = EXCLUDED.sort_order, + updated_at = NOW() + """) + async with async_session_maker() as session: + await session.execute(sql, { + "plan": plan, + "display_name": display_name, + "description": description, + "monthly_price_cents": monthly_price_cents, + "stripe_product_id": stripe_product_id, + "stripe_monthly_price_id": stripe_monthly_price_id, + "is_public": is_public, + "sort_order": sort_order, + }) + await session.commit() + logger.info("upserted plan_billing for plan=%s", plan) + + +async def main(dry_run: bool) -> int: + if not settings.STRIPE_SECRET_KEY: + logger.error("STRIPE_SECRET_KEY is not set. Refusing to run.") + return 2 + + stripe.api_key = settings.STRIPE_SECRET_KEY + mode = "live" if settings.STRIPE_SECRET_KEY.startswith("sk_live_") else "test" + logger.info("connected to Stripe in %s mode", mode) + + errors: list[str] = [] + + for product_name, plan in PLAN_NAME_TO_SLUG.items(): + defaults = PLAN_DEFAULTS[plan] + product = find_product_by_name(product_name) + if product is None: + errors.append(f"Stripe product not found: {product_name!r}") + continue + + price = None + if plan in PLANS_REQUIRING_PRICE: + price = find_active_monthly_price(product.id) + if price is None: + errors.append( + f"No active monthly recurring price for {product_name!r} " + f"(product {product.id})" + ) + continue + + await upsert_plan_billing( + plan=plan, + display_name=product.name, + description=product.description, + monthly_price_cents=price.unit_amount if price else None, + stripe_product_id=product.id, + stripe_monthly_price_id=price.id if price else None, + sort_order=defaults["sort_order"], + is_public=defaults["is_public"], + dry_run=dry_run, + ) + + if errors: + for e in errors: + logger.error(e) + return 1 + logger.info("done") + return 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dry-run", action="store_true", help="Log actions without writing.") + args = parser.parse_args() + sys.exit(asyncio.run(main(dry_run=args.dry_run))) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 5f1d21c9..cefd8beb 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -172,8 +172,9 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]: INSERT INTO plan_limits (plan, max_trees, max_sessions_per_month, max_users, custom_branding, priority_support, export_formats) VALUES ('free', 3, 20, 1, false, false, '["markdown", "text"]'), + ('starter', 10, 75, 1, false, false, '["markdown", "text", "html"]'), ('pro', 25, 200, 5, true, false, '["markdown", "text", "html"]'), - ('team', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]') + ('enterprise', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]') """)) # Seed the platform/system account (PLATFORM_ACCOUNT_ID) needed by diff --git a/backend/tests/test_admin_plan_limits.py b/backend/tests/test_admin_plan_limits.py index 8eb22d45..643ee75c 100644 --- a/backend/tests/test_admin_plan_limits.py +++ b/backend/tests/test_admin_plan_limits.py @@ -122,9 +122,9 @@ class TestAdminPlanLimits: ): """PUT /admin/plan-limits upserts a plan_billing row when billing fields are included in the body.""" - # Ensure no plan_billing row exists for "team" yet. + # Ensure no plan_billing row exists for "enterprise" yet. existing = (await test_db.execute( - select(PlanBilling).where(PlanBilling.plan == "team") + select(PlanBilling).where(PlanBilling.plan == "enterprise") )).scalar_one_or_none() if existing is not None: await test_db.delete(existing) @@ -133,7 +133,7 @@ class TestAdminPlanLimits: response = await client.put( "/api/v1/admin/plan-limits", json={ - "plan": "team", + "plan": "enterprise", "max_trees": None, "max_sessions_per_month": None, "max_users": None, @@ -163,7 +163,7 @@ class TestAdminPlanLimits: # Confirm the row was actually persisted. await test_db.commit() # ensure session sees other-session writes pb = (await test_db.execute( - select(PlanBilling).where(PlanBilling.plan == "team") + select(PlanBilling).where(PlanBilling.plan == "enterprise") )).scalar_one_or_none() assert pb is not None assert pb.display_name == "Team" @@ -179,17 +179,17 @@ class TestAdminPlanLimits: plan_billing row when the caller passes explicit nulls. The set of guarded fields is {display_name, is_public, is_archived, sort_order}. """ - # Seed a plan_billing row for "team" with non-default values for every + # Seed a plan_billing row for "enterprise" with non-default values for every # NOT NULL field so we can detect any clobbering. existing = (await test_db.execute( - select(PlanBilling).where(PlanBilling.plan == "team") + select(PlanBilling).where(PlanBilling.plan == "enterprise") )).scalar_one_or_none() if existing is not None: await test_db.delete(existing) await test_db.commit() seeded = PlanBilling( - plan="team", + plan="enterprise", display_name="Team Seeded", is_public=False, is_archived=True, @@ -201,7 +201,7 @@ class TestAdminPlanLimits: response = await client.put( "/api/v1/admin/plan-limits", json={ - "plan": "team", + "plan": "enterprise", "max_trees": None, "max_sessions_per_month": None, "max_users": None, @@ -221,7 +221,7 @@ class TestAdminPlanLimits: # Confirm the seeded NOT NULL values were preserved. await test_db.commit() # ensure session sees writes from the request pb = (await test_db.execute( - select(PlanBilling).where(PlanBilling.plan == "team") + select(PlanBilling).where(PlanBilling.plan == "enterprise") )).scalar_one_or_none() assert pb is not None assert pb.display_name == "Team Seeded" diff --git a/backend/tests/test_invite_plan.py b/backend/tests/test_invite_plan.py index d33c8b99..e6b31051 100644 --- a/backend/tests/test_invite_plan.py +++ b/backend/tests/test_invite_plan.py @@ -49,7 +49,7 @@ class TestInviteCodeCreation: ): response = await client.post( "/api/v1/invites", - json={"assigned_plan": "team", "email": "beta@example.com"}, + json={"assigned_plan": "enterprise", "email": "beta@example.com"}, headers=admin_auth_headers, ) assert response.status_code == 201 @@ -149,7 +149,7 @@ class TestRegistrationWithInvitePlan: # Create team invite without trial resp = await client.post( "/api/v1/invites", - json={"assigned_plan": "team"}, + json={"assigned_plan": "enterprise"}, headers=admin_auth_headers, ) code = resp.json()["code"] @@ -172,7 +172,7 @@ class TestRegistrationWithInvitePlan: sub = (await test_db.execute( select(Subscription).where(Subscription.account_id == user.account_id) )).scalar_one() - assert sub.plan == "team" + assert sub.plan == "enterprise" assert sub.status == "active" diff --git a/backend/tests/test_plans_public.py b/backend/tests/test_plans_public.py index a676009a..f4f17260 100644 --- a/backend/tests/test_plans_public.py +++ b/backend/tests/test_plans_public.py @@ -14,7 +14,12 @@ from app.models.plan_limits import PlanLimits async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None: - """Ensure a plan_limits row exists for the given plan name.""" + """Ensure a plan_limits row exists with the given max_users. + + Upserts: conftest seeds the canonical plans (free/starter/pro/enterprise) + so this helper has to overwrite max_users when a test wants different + values for fixture-driven assertions. + """ existing = await test_db.get(PlanLimits, plan) if existing is None: test_db.add( @@ -28,7 +33,9 @@ async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None: export_formats=["markdown", "text"], ) ) - await test_db.commit() + else: + existing.max_users = max_users + await test_db.commit() class TestGetPlansPublic: diff --git a/frontend/src/hooks/useSubscription.ts b/frontend/src/hooks/useSubscription.ts index 715a86bb..d3b31381 100644 --- a/frontend/src/hooks/useSubscription.ts +++ b/frontend/src/hooks/useSubscription.ts @@ -8,7 +8,7 @@ export function useSubscription() { const usage = subscription?.usage ?? null const isActive = subscription?.subscription.status === 'active' || subscription?.subscription.status === 'trialing' - const isPaidPlan = plan === 'pro' || plan === 'team' + const isPaidPlan = plan === 'pro' || plan === 'starter' || plan === 'enterprise' const canUseFeature = (feature: 'custom_branding' | 'priority_support'): boolean => { if (!limits) return false -- 2.49.1 From a628b2410d87b05ff94c7c8196b1b7eb59c4cce6 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 7 May 2026 15:59:57 -0400 Subject: [PATCH 2/6] chore(dev): pass STRIPE_* env to backend container; add repo-root .env.example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 10 ++++++++++ docker-compose.dev.yml | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..9c55f759 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +REPO_ROOT=/opt/docker/code-server/workspace/resolutionflow +POSTGRES_PORT=5433 +SECRET_KEY= +ANTHROPIC_API_KEY= +GOOGLE_AI_API_KEY= + +STRIPE_SECRET_KEY=sk_test_ +STRIPE_PUBLISHABLE_KEY=pk_test_ +STRIPE_WEBHOOK_SECRET=whsec_ +VITE_STRIPE_PUBLISHABLE_KEY=pk_test_ \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5d6454db..97b88b37 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -40,11 +40,14 @@ services: - ALGORITHM=HS256 - ACCESS_TOKEN_EXPIRE_MINUTES=15 - REFRESH_TOKEN_EXPIRE_DAYS=7 - - REQUIRE_INVITE_CODE=true + - REQUIRE_INVITE_CODE=false - FEEDBACK_EMAIL=feedback@resolutionflow.com - AI_PROVIDER=anthropic - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY:-} + - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} + - STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-} + - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-} - ENABLE_MCP_MICROSOFT_LEARN=true - FRONTEND_URL=http://docker-01:5173 - CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173","http://docker-01:5173","http://100.64.78.44:5173"] -- 2.49.1 From 8494366ec61aa00f0635ca337c1ac3720607ff89 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 7 May 2026 16:57:25 -0400 Subject: [PATCH 3/6] feat(billing): add INTERNAL_TESTER_EMAILS allowlist for self-serve soft cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/.env.example | 12 +++- backend/app/api/deps.py | 34 +++++++++ backend/app/api/endpoints/auth.py | 2 +- backend/app/api/endpoints/config.py | 18 +++-- backend/app/core/config.py | 34 +++++++++ backend/tests/test_config_public.py | 104 ++++++++++++++++++++++++++++ docker-compose.dev.yml | 2 + 7 files changed, 200 insertions(+), 6 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 28880cdb..05d46396 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -29,4 +29,14 @@ CW_CLIENT_ID= # When unset, app/core/config.py:stripe_enabled returns False and Stripe code paths short-circuit. STRIPE_SECRET_KEY=sk_test_ STRIPE_PUBLISHABLE_KEY=pk_test_ -STRIPE_WEBHOOK_SECRET=whsec_ \ No newline at end of file +STRIPE_WEBHOOK_SECRET=whsec_ + +# Self-serve cutover +# SELF_SERVE_ENABLED is the master switch for the public self-serve signup +# flow (pricing page, invite-code-optional registration). Default is false +# until Phase O cutover. +# INTERNAL_TESTER_EMAILS is a comma-separated allowlist that bypasses the +# global flag for specific users — used for prod test-mode validation +# before the public flip. Empty by default. +SELF_SERVE_ENABLED=false +INTERNAL_TESTER_EMAILS= \ No newline at end of file diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 32ada630..67717d45 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -64,6 +64,40 @@ async def get_current_user( return user +async def get_current_user_optional( + request: Request, + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> Optional[User]: + """Best-effort current user for endpoints that work both anonymous and authed. + + Returns None on missing/invalid/expired token instead of raising. Used by + surfaces like /config/public that anonymous clients can hit but where an + authenticated user gets a tailored response (e.g. INTERNAL_TESTER_EMAILS + allowlist override). + """ + auth_header = request.headers.get("Authorization") or request.headers.get("authorization") + if not auth_header or not auth_header.lower().startswith("bearer "): + return None + token = auth_header.split(None, 1)[1].strip() + if not token: + return None + + payload = decode_token(token) + if payload is None or payload.get("type") != "access": + return None + + user_id = payload.get("sub") + if user_id is None: + return None + try: + user_uuid = UUID(user_id) + except ValueError: + return None + + result = await db.execute(select(User).where(User.id == user_uuid)) + return result.scalar_one_or_none() + + async def get_refresh_token_payload( token: Annotated[str, Depends(oauth2_scheme)] ) -> dict: diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 7b8a1ae1..62cce07c 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -150,7 +150,7 @@ async def register( # and so paid/trial-bearing codes still apply when supplied. if ( settings.REQUIRE_INVITE_CODE - and not settings.SELF_SERVE_ENABLED + and not settings.is_self_serve_active_for(user_data.email) and not user_data.invite_code ): raise HTTPException( diff --git a/backend/app/api/endpoints/config.py b/backend/app/api/endpoints/config.py index a621e738..3ae5156a 100644 --- a/backend/app/api/endpoints/config.py +++ b/backend/app/api/endpoints/config.py @@ -11,22 +11,31 @@ frontend codegen and other call sites if needed. from __future__ import annotations -from fastapi import APIRouter +from typing import Annotated, Optional +from fastapi import APIRouter, Depends + +from app.api.deps import get_current_user_optional from app.core.config import settings +from app.models.user import User from app.schemas.config import PublicConfigResponse router = APIRouter(prefix="/config", tags=["config"]) @router.get("/public", response_model=PublicConfigResponse) -async def get_public_config() -> PublicConfigResponse: +async def get_public_config( + current_user: Annotated[Optional[User], Depends(get_current_user_optional)], +) -> PublicConfigResponse: """Return public-safe runtime config. `oauth_providers` reflects which OAuth client IDs are configured server side; the frontend uses it to render only buttons that will actually succeed. `self_serve_enabled` is the master switch for the new public - self-serve signup flow. + self-serve signup flow; an authenticated caller whose email is on the + INTERNAL_TESTER_EMAILS allowlist sees `True` even when the global flag + is off, so internal validation in prod test mode can exercise the full + surface before the public flip. """ providers: list[str] = [] if settings.GOOGLE_CLIENT_ID: @@ -34,7 +43,8 @@ async def get_public_config() -> PublicConfigResponse: if settings.MS_CLIENT_ID: providers.append("microsoft") + user_email = current_user.email if current_user else None return PublicConfigResponse( - self_serve_enabled=settings.SELF_SERVE_ENABLED, + self_serve_enabled=settings.is_self_serve_active_for(user_email), oauth_providers=providers, ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 815c95db..9c5bd838 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -97,6 +97,40 @@ class Settings(BaseSettings): STRIPE_WEBHOOK_SECRET: Optional[str] = None SELF_SERVE_ENABLED: bool = False + # Internal tester allowlist for soft cutover. Comma-separated emails; + # when SELF_SERVE_ENABLED is False, listed users still see the self-serve + # surfaces (pricing page, invite-code-optional registration, etc.) so the + # full flow can be exercised in prod test mode before public flip. + INTERNAL_TESTER_EMAILS: list[str] = [] + + @field_validator("INTERNAL_TESTER_EMAILS", mode="before") + @classmethod + def split_internal_tester_emails(cls, v) -> list[str]: + """Parse a comma-separated string into a normalized lowercase list.""" + if v is None or v == "": + return [] + if isinstance(v, list): + return [e.strip().lower() for e in v if e and e.strip()] + if isinstance(v, str): + return [e.strip().lower() for e in v.split(",") if e.strip()] + return [] + + def is_internal_tester(self, email: Optional[str]) -> bool: + """Case-insensitive allowlist check. None/empty email is never a tester.""" + if not email: + return False + return email.lower() in self.INTERNAL_TESTER_EMAILS + + def is_self_serve_active_for(self, email: Optional[str]) -> bool: + """True if self-serve surfaces should render for this user. + + Either the global flag is on, or the user is on the internal-tester + allowlist. Anonymous calls (email is None) only see the global flag. + """ + if self.SELF_SERVE_ENABLED: + return True + return self.is_internal_tester(email) + @property def stripe_enabled(self) -> bool: """Check if Stripe is configured.""" diff --git a/backend/tests/test_config_public.py b/backend/tests/test_config_public.py index c68738a3..e06e886f 100644 --- a/backend/tests/test_config_public.py +++ b/backend/tests/test_config_public.py @@ -49,6 +49,58 @@ class TestConfigPublic: assert response.status_code == 200 assert response.json()["oauth_providers"] == ["microsoft"] + @pytest.mark.asyncio + async def test_get_config_public_returns_true_for_internal_tester( + self, + client: AsyncClient, + auth_headers: dict, + test_user: dict, + monkeypatch: pytest.MonkeyPatch, + ): + """Authenticated user whose email is on INTERNAL_TESTER_EMAILS sees + self_serve_enabled=True even when the global flag is off.""" + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False) + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None) + monkeypatch.setattr(settings, "MS_CLIENT_ID", None) + monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", [test_user["email"].lower()]) + + response = await client.get("/api/v1/config/public", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["self_serve_enabled"] is True + + @pytest.mark.asyncio + async def test_get_config_public_returns_false_for_non_tester_when_global_off( + self, + client: AsyncClient, + auth_headers: dict, + monkeypatch: pytest.MonkeyPatch, + ): + """Authenticated user NOT on the allowlist sees the global flag — + prevents accidental opt-in via stale credentials or empty allowlist.""" + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False) + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None) + monkeypatch.setattr(settings, "MS_CLIENT_ID", None) + monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", ["someone-else@example.com"]) + + response = await client.get("/api/v1/config/public", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["self_serve_enabled"] is False + + @pytest.mark.asyncio + async def test_get_config_public_anonymous_ignores_allowlist( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ): + """Anonymous callers always see the global flag — the allowlist is + keyed on authenticated identity, not request content.""" + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False) + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None) + monkeypatch.setattr(settings, "MS_CLIENT_ID", None) + monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", ["anon-tester@example.com"]) + + response = await client.get("/api/v1/config/public") + assert response.status_code == 200 + assert response.json()["self_serve_enabled"] is False + class TestRegisterInviteCodeGate: """Regression + new-behavior tests for /auth/register vs SELF_SERVE_ENABLED.""" @@ -98,3 +150,55 @@ class TestRegisterInviteCodeGate: assert body["email"] == "self-serve@example.com" assert body["account_role"] == "owner" assert "account_id" in body + + @pytest.mark.asyncio + async def test_register_invite_code_optional_for_internal_tester( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ): + """SELF_SERVE_ENABLED is False but the registering email is on + INTERNAL_TESTER_EMAILS — registration should succeed without an + invite code, matching the per-email soft-cutover behavior.""" + monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True) + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False) + monkeypatch.setattr( + settings, "INTERNAL_TESTER_EMAILS", ["tester@example.com"] + ) + + response = await client.post( + "/api/v1/auth/register", + json={ + "email": "tester@example.com", + "password": "SecurePass123!", + "name": "Internal Tester", + }, + ) + + assert response.status_code == 201, response.text + body = response.json() + assert body["email"] == "tester@example.com" + assert body["account_role"] == "owner" + + @pytest.mark.asyncio + async def test_register_blocked_for_non_tester_when_self_serve_disabled( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ): + """Registering with an email NOT on the allowlist still 400s when + self-serve is off and no invite code is provided. Prevents the + allowlist from leaking to public users.""" + monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True) + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False) + monkeypatch.setattr( + settings, "INTERNAL_TESTER_EMAILS", ["other@example.com"] + ) + + response = await client.post( + "/api/v1/auth/register", + json={ + "email": "outsider@example.com", + "password": "SecurePass123!", + "name": "Outsider", + }, + ) + + assert response.status_code == 400 + assert "invite code is required" in response.json()["detail"].lower() diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 97b88b37..337635e5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -48,6 +48,8 @@ services: - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} - STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-} - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-} + - SELF_SERVE_ENABLED=${SELF_SERVE_ENABLED:-false} + - INTERNAL_TESTER_EMAILS=${INTERNAL_TESTER_EMAILS:-} - ENABLE_MCP_MICROSOFT_LEARN=true - FRONTEND_URL=http://docker-01:5173 - CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173","http://docker-01:5173","http://100.64.78.44:5173"] -- 2.49.1 From 8649a4aa29141aef67e81f67be9c1be99fd69523 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 7 May 2026 22:56:01 -0400 Subject: [PATCH 4/6] docs: refresh CURRENT-STATE, ROADMAP, README, DECISIONS for self-serve cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .ai/DECISIONS.md | 48 +++++++++++++++++++++++++++++++++++++++ .env.example | 4 +++- 03-DEVELOPMENT-ROADMAP.md | 37 ++++++++++++++++++++++++++---- CURRENT-STATE.md | 37 ++++++++++++++++++++++++++---- README.md | 24 ++++++++++++++------ 5 files changed, 132 insertions(+), 18 deletions(-) diff --git a/.ai/DECISIONS.md b/.ai/DECISIONS.md index df018ccb..df582fcf 100644 --- a/.ai/DECISIONS.md +++ b/.ai/DECISIONS.md @@ -13,6 +13,54 @@ --- +## 2026-05-07 — Per-email allowlist (`INTERNAL_TESTER_EMAILS`) for self-serve soft cutover + +**Context:** Phase O Task 46 ("internal validation pass") needed a way to exercise the full self-serve flow against the prod backend before flipping `SELF_SERVE_ENABLED=true` for everyone. The plan doc described the mechanism but the backend support was never built — flagged in `SESSION_LOG.md` as a code blocker. Stripe live-mode setup is also gated on having a working internal-tester path in prod test mode. + +**Decision:** Comma-separated allowlist `INTERNAL_TESTER_EMAILS` parsed by a Pydantic field_validator into a normalized lowercase list. Two helpers on `Settings`: `is_internal_tester(email)` (case-insensitive membership check) and `is_self_serve_active_for(email)` (returns `SELF_SERVE_ENABLED OR is_internal_tester(email)`). Both endpoints that gate on the global flag now call the helper: +- `/config/public` accepts optional auth via new `get_current_user_optional` dep; returns `self_serve_enabled=true` for allowlisted authenticated callers; anonymous calls always see the global flag. +- `/auth/register` allows allowlisted emails to register without an invite code. + +**Rejected:** +- **Custom header `X-Internal-Tester-Email` for anonymous flows.** Spoofable. The auth/register-payload checks are sufficient because the user has to OWN the email to register or log in. +- **Separate allowlists per surface (`INTERNAL_PRICING_TESTERS`, `INTERNAL_OAUTH_TESTERS`).** Premature splitting. The Phase O use case is "this small set of people can see the new flow"; one variable handles it. If finer granularity emerges, split then. +- **Database table for the allowlist.** Env var matches the spec from the plan doc and fits the soft-cutover lifecycle — list is small, changes infrequently, lives alongside other deployment-time config. + +**Consequences:** +- Stripe internal validation can run end-to-end in prod test mode without flipping the global flag. +- Anonymous callers always see the global flag — the allowlist never leaks via unauthenticated request content. Three regression tests in `test_config_public.py` enforce this. +- `INTERNAL_TESTER_EMAILS` plumbed through `docker-compose.dev.yml` and documented in `backend/.env.example`. Railway prod env will need the same var set during Phase O cutover. + +--- + +## 2026-05-07 — Reconcile plan tier taxonomy (rename `team` → `enterprise`, add `starter`) + +**Context:** PR #162 left a real architectural gap. Marketing surface (PricingPage, Stripe products) was wired for `Starter / Pro / Enterprise` while backend was on `free / pro / team`. `plan_billing.plan` FK referenced `plan_limits.plan` so the `BillingPlan` schema's `Literal["pro", "starter", "team", "enterprise"]` could accept values that violated the FK. `plan_billing` was unseeded in dev, so no checkout could complete. `Subscription.plan.in_(["pro", "team"])` paid-plan checks wouldn't recognize `enterprise`. Self-serve cutover was blocked at the data layer. + +**Decision:** Reconcile to a single taxonomy — backend slugs become `free / pro / starter / enterprise`, matching the marketing surface and Stripe products. Migration `4ce3e594cb87`: +1. Defensive `UPDATE subscriptions SET plan='enterprise' WHERE plan='team'` (dev had zero such rows; safety for any prod stragglers). +2. Rename the `plan_limits.plan='team'` row to `'enterprise'`. +3. Insert a `starter` row with caps interpolated between free and pro: `max_trees=10`, `max_sessions=75`, `max_users=1`, `max_ai_builds_per_month=15`, no KB Accelerator, no custom branding, no priority support. + +Code rename across schemas, `Subscription` paid-plan/`has_pro_entitlement` checks, admin endpoints, frontend `useSubscription.isPaidPlan`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched — that string means "shared with my account" and has nothing to do with the subscription tier. + +New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert of `plan_billing` rows from Stripe products by exact name match (`ResolutionFlow Starter / Pro / Enterprise`). Picks the active monthly recurring price for tiers that have one. Annual fields stay NULL by design — annual pricing is intentionally out of scope for the soft cutover ("want to be able to exit if necessary without breaching any terms"). + +**Rejected:** +- **Map marketing names to existing slugs (Option A from the discussion).** Smallest diff but means PricingPage cards have to translate `enterprise` → `team` at render time, and "Starter" can't exist as a real backend tier — it'd have to be hidden or dropped. Kicks the can. +- **Add `starter` only, keep `team` slug as cosmetic enterprise (Option C).** Mixed taxonomy across layers — slug-vs-display-name divergence guarantees confusion in 6 months. Compromise that's worse than either pure choice. +- **Annual pricing in this iteration.** User's explicit constraint: skip annual to keep exit-flexibility. Schema columns (`annual_price_cents`, `stripe_annual_price_id`) preserved as nullable for future re-enable. +- **Auto-archive the existing Enterprise `$500/mo` test-mode price.** Done manually via Stripe MCP after un-setting the product's `default_price` first. Spec says Enterprise is sales-led with no catalog price. + +**Consequences:** +- `plan_billing` table is now seedable and seeded. Test-mode `plan_billing` populated for all 3 tiers via `sync_stripe_plan_ids.py`. Live mode runs the same script after manual Dashboard setup of products + prices. +- New consumers of `Subscription.plan` literal must use `("free", "pro", "starter", "enterprise")`. Three call sites already updated. Backend-wide grep is the safety net for new ones. +- `Subscription.is_paid` and `has_pro_entitlement` now include `starter` — Starter is a paid tier with a real $19.99/mo price. +- 86/86 passing across the subscription/billing/plan/invite/admin sweep after the rename. +- Test fixtures: `conftest.py` plan_limits seed updated to the new taxonomy. `_seed_plan_limits` helper in `test_plans_public.py` is now a true upsert so tests can override `max_users` even when conftest seeded the canonical value. + +--- + ## 2026-05-07 — Standardize backend Python on 3.12 **Context:** Runtime facts had drifted from docs. The backend Dockerfiles and running dev container were already on Python 3.12, GitHub CI had just been updated to 3.12, but project docs still said Python 3.11 and Gitea CI relied on the runner's ambient Python. diff --git a/.env.example b/.env.example index 9c55f759..e836299b 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,6 @@ GOOGLE_AI_API_KEY= STRIPE_SECRET_KEY=sk_test_ STRIPE_PUBLISHABLE_KEY=pk_test_ STRIPE_WEBHOOK_SECRET=whsec_ -VITE_STRIPE_PUBLISHABLE_KEY=pk_test_ \ No newline at end of file +VITE_STRIPE_PUBLISHABLE_KEY=pk_test_ + +INTERNAL_TESTER_EMAILS=internaltest@resolutionflow.com \ No newline at end of file diff --git a/03-DEVELOPMENT-ROADMAP.md b/03-DEVELOPMENT-ROADMAP.md index bd226221..5e2bd7c6 100644 --- a/03-DEVELOPMENT-ROADMAP.md +++ b/03-DEVELOPMENT-ROADMAP.md @@ -1,11 +1,25 @@ # Development Roadmap -> **Last Updated:** March 18, 2026 -> **Product:** ResolutionFlow (repo: patherly) +> **Last Updated:** May 7, 2026 +> **Product:** ResolutionFlow (repo path: `resolutionflow/`; `patherly` is the legacy internal name) > **Target Market:** MSP companies — IT service providers managing infrastructure and support for multiple clients --- +## Status as of 2026-05-07 + +The historical phase content below (Phase 1 through Phase 5) is preserved as a factual record. **This section is the live status overlay — read it first.** + +**Where we are:** Pre-PMF, Go-to-Market Validation. Backend feature-complete (50+ endpoints, 100+ tests). FlowPilot session UX is the daily-driver surface and recently went through PR #155 (escalation wedge), #156 (`applied_pending` non-terminal status), #158 (impeccable pass + tasklane keyboard flow), #159 (Diátaxis User Guides), #160 (sidebar IA + account redesign). + +**Currently in flight:** Self-serve signup cutover. Phase 1 backend (#161) and Phase 2 frontend (#162) merged. PR #164 (open) closes the last code blockers — plan taxonomy reconciliation (`team` → `enterprise`, add `starter`) and `INTERNAL_TESTER_EMAILS` allowlist for the soft cutover. After merge, remaining work is **manual operations only**: Stripe Dashboard live-mode setup, Railway prod env vars, internal validation pass, public flag flip. See `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md` Phase O for the checklist. + +**Product thesis being tested:** "We're not a documentation app. We are the documentation builders." Captured in `~/.gstack/projects/chihlasm-resolutionflow/abc-feat-self-serve-signup-phase-2-design-20260507-112020.md` (office-hours design doc). Pre-build assignment: 3 calls with external Directors of Onboarding (cold, no friendly contacts) to validate the framing before adopting it as the public positioning. + +**What's not yet decided:** Whether to formally cut branching Flows from the pilot UI surface in favor of a Project (linear procedure) + FlowPilot + Documentation-Builder positioning. Discussed in /office-hours but no implementation work scheduled — gated on the 3 external validation calls. + +--- + ## Completed Work ### Phase 1: MVP @@ -72,13 +86,26 @@ | Task | Status | Notes | |------|--------|-------| -| ConnectWise PSA Integration (Advanced) | In Progress | Core done — ticket linking, note posting, member mapping. Remaining: callback webhooks, deeper ticket context in sessions | -| PR #114 Merge | In Progress | Empty states, onboarding, PDF exports, branding, supporting data — ready for review | +| Self-serve signup cutover (Phase O) | In Progress | PR #164 merge → Stripe live-mode Dashboard setup → Railway prod env vars → internal validation → public flag flip. Code blockers cleared by #164 (taxonomy + `INTERNAL_TESTER_EMAILS` allowlist). | +| External validation of documentation-builder thesis | Not started | 3 calls with external Directors of Onboarding (cold). Decision gate before scoping a "Day 1 onboarding checklist" build. | +| ConnectWise PSA Integration (Advanced) | Deferred | Core complete — ticket linking, note posting, member mapping, ticket context retrieval. Callback webhooks deferred until pilot signal demands them. | --- ## What's Next +### Phase O Cutover (Weeks 0-1) + +| Step | Status | +|---|---| +| Merge PR #164 (taxonomy reconciliation + allowlist) | Open, CI green | +| Stripe Dashboard live-mode setup (Products + Prices for Starter/Pro, no Prices on Enterprise, Customer Portal config, webhook endpoint with 5 events) | Manual op | +| Railway prod env vars (`sk_live_*`, `whsec_*`, `INTERNAL_TESTER_EMAILS`, prod Google + Microsoft OAuth credentials, `OAUTH_REDIRECT_BASE`, `STRIPE_PUBLISHABLE_KEY`, `VITE_STRIPE_PUBLISHABLE_KEY` for frontend redeploy) | Manual op | +| Run `python -m scripts.sync_stripe_plan_ids` against prod backend; verify `plan_billing` has `sk_live_*` price IDs | Manual op | +| Internal validation pass (9 scenarios from Phase O Task 46) | Manual op | +| Email pilots about complimentary status, flip `SELF_SERVE_ENABLED=true` (frontend redeploy required for `VITE_SELF_SERVE_ENABLED`) | Manual op | +| PostHog signup-funnel dashboard + Sentry alert at >1/hour Stripe webhook errors | Manual op | + ### Near-Term Priorities (from Stack Priorities Plan) | Feature | Status | Description | @@ -86,7 +113,7 @@ | Coverage gates in CI | ✅ Complete | Backend enforced at 80%, frontend coverage reporting enabled | | Security headers | ✅ Complete | HSTS, CSP (report-only), X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy | | Web Vitals / performance budgets | ✅ Complete | LCP, INP, CLS, FCP, TTFB reported to PostHog via web-vitals | -| Search and recall improvements | ⬜ Not started | Search sessions by flow, tag, client, ticket context | +| Search and recall improvements | ✅ Complete | Structured filters + FTS + Voyage AI semantic search shipped (see CURRENT-STATE.md "Search & Recall" section) | ### 3A: Quick Wins & UX (Priority: Medium) diff --git a/CURRENT-STATE.md b/CURRENT-STATE.md index c145417a..956bb596 100644 --- a/CURRENT-STATE.md +++ b/CURRENT-STATE.md @@ -2,16 +2,30 @@ > **Purpose:** Quick-reference file showing exactly where the project stands. > **For Claude Code:** Read this first to understand what's done and what's next. -> **Last Updated:** May 1, 2026 +> **Last Updated:** May 7, 2026 --- -## Active Phase: Go-to-Market Validation (Pre-PMF) +## Active Phase: Go-to-Market Validation (Pre-PMF) — Self-serve cutover (Phase O) in flight + +Self-serve signup backend (Phase 1) and frontend (Phase 2) are merged. Cutover (Phase O) is gated on manual ops: live-mode Stripe Dashboard config, Railway prod env vars, internal validation pass against prod test mode, then the public flag flip. Plan: `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md`. --- ## Recently shipped (post-0.1.0.0) +- **2026-05-07 — PR #164 (open)** Plan taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist. Marketing surface (PricingPage, Stripe products) used `Starter / Pro / Enterprise` while backend was on `free / pro / team`, leaving `plan_billing` unseeded and `BillingPlan` schema accepting a literal that violated the FK. Migration `4ce3e594cb87`: rename `team` → `enterprise` in `plan_limits`, add `starter` row (caps interpolated between free and pro: `max_trees=10`, `sessions=75`, `ai=15/mo`), defensive update of any subscriptions on the `team` slug. Code rename across schemas, `Subscription` paid-plan checks, admin endpoints, and frontend `useSubscription`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched. New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert of `plan_billing` rows from Stripe products by exact name match, picks active monthly recurring price, leaves annual fields NULL by design. Test-mode `plan_billing` populated for all 3 tiers in dev. Phase O Task 46 allowlist: `INTERNAL_TESTER_EMAILS` env var (comma-separated) bypasses `SELF_SERVE_ENABLED=false` for specific authenticated users — `Settings.is_self_serve_active_for(email)` centralizes the check; `/config/public` returns `self_serve_enabled=true` for allowlisted authenticated callers; `/auth/register` allows allowlisted emails to register without invite code. New `get_current_user_optional` dep for endpoints that work both anonymous and authed. + +- **2026-05-06 — PR #163** Seed test users marked email-verified. Fixed seeded users showing the email verification banner in dev/test, blocking flows that gate on `email_verified=True`. Squash-merged into main as `dad5e1f`. + +- **2026-05-06 — PR #162** Self-serve signup Phase 2 (frontend cutover). 18 commits across Tasks 27–44 of the Phase 2 plan: backend remainders + frontend billing foundation + auth surfaces (OAuth + accept-invite + verify-email) + welcome wizard + dashboard redesign (TrialPill, NextStepCard, unified checklist) + public surfaces (`/pricing`, `/contact-sales`) + beta-signup deprecation. Single alembic head `c6cbfc534fad` (no new migrations in Phase 2). Squash-merged as `f1be3ab`. + +- **2026-05-?? — PR #161** Self-serve signup backend (Phase 1). `plan_billing` sibling table for Stripe + catalog metadata, `sales_leads` and `stripe_events` tables, `complimentary` status with `has_pro_entitlement`, `BillingService.start_trial` wired into `/auth/register`, `/billing/checkout-session`, Stripe webhook handler with idempotency via `stripe_events`, Google + Microsoft OAuth callbacks with `oauth_identities` linking, `require_verified_email_after_grace` + `require_active_subscription` guards, bulk-create + soft-revoke invite endpoints, account-invite email-match enforcement, pilot complimentary backfill, `accounts.team_size_bucket` + `primary_psa` for wizard. Squash-merged as `f918b76`. + +- **2026-05-02 — PR #159** In-product User Guides rewrite to Diátaxis how-tos. Replaced 15 feature-dump guides with 43 problem-oriented how-tos grouped under 10 categories. Dropped Maintenance Flows / AI Assistant / Flow Assist Sparkles guides (UI no longer exists). Renamed Step Library → Solutions Library. Authored 14 net-new how-tos for FlowPilot-era surfaces (tasklane keyboard flow, what-we-know, resolve, escalate, record-fix-outcome, post-docs-to-ticket, share-update, pause-and-leave, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate, etc.). Schema additions: `category`, optional `relatedSlugs`. Browser-verified against engineer + owner login. + +- **2026-05-?? — PR #160** Post-PR-159 UI cleanup — sidebar IA + account redesign. Squash-merged as `a8b22cf`. + - **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Heuristic score 24/40 → 33/40 across five sub-passes (distill, quieter, layout, typeset, polish). Removed duplicate "Suggested checks" chip strip → TaskLane is the single source of truth; added inline `Next steps · N pending` cue on the latest action-bearing AI bubble; consolidated session header to Resolve + Escalate + ⋯ kebab; centered messages column to match composer; dropped all banned decorations (side stripes, gradient surfaces, backdrop blur, accent borderTop) for a single decoration channel per surface; unified 14 text sizes into a 5-step scale. TaskLane keyboard flow: Enter submits + auto-advances, Shift+Enter newline, Esc cancel, focus jumps to Send after the last task. Banner ↔ script-panel are now linked (collapse hides both, any outcome closes both). WhatWeKnow section is collapsible with `sessionStorage` memory + auto-collapse-at-5-facts. Side fix: ParameterizationPreview no longer over-highlights short parameter values (word-boundary check). Two backlog entries logged in `.ai/TODO.md`: ConcludeSessionModal multi-select and `bg-card-hover` Tailwind drift in CommandPalette. - **2026-05-01 — PR #156** Suggested-fix "Awaiting verification" outcome. Engineers can now park a fix in `applied_pending` (waiting on client power-cycle, AD replication, license sync, etc.) instead of forcing a synchronous worked/didn't/partial verdict. PendingBanner with worked / didn't / update reason / dismiss; nudge "Still checking" records pending with a reason; page-level Resolve auto-patches pending → success before the resolution flow opens; page-level Escalate intercepts pending. Migration `c0f3a4b7e91d` (`pending_reason` column + status CHECK constraint). - **2026-04-30 — PR #155** Escalation Mode wedge. Magic-moment handoff-context screen for senior pickup, live SSE escalation arrivals, post-claim time-to-first-action metric (`GET /analytics/flowpilot/escalations`), atomic role-gated claim with conflict resolution, queue self-exclusion, chat ownership extended to claimed sessions. The wedge for the first paying-customer push. @@ -215,17 +229,30 @@ ## What's In Progress -- **GTM Validation:** Shadow & Ship — founder uses product for 2 weeks, then hands logins to 5 colleagues -- **Solutions Library spec:** Written at `docs/plans/2026-03-23-solutions-library-design.md`, implementation deferred to post-pilot +- **Self-serve cutover (Phase O):** PR #164 (open) closes the last code blockers — taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist. After merge, remaining work is purely manual ops: live-mode Stripe Dashboard config, Railway prod env vars, internal validation pass with Andrea Henry + 2-3 external Directors of Onboarding, then `SELF_SERVE_ENABLED=true` flip with frontend redeploy. +- **Stripe live-mode setup:** Test-mode is fully wired (3 products, monthly prices for Starter/Pro, Enterprise sales-led, `plan_billing` seeded via `sync_stripe_plan_ids.py`). Live mode requires manual Dashboard config — same script handles seeding live IDs. +- **GTM Validation:** Shadow & Ship — founder uses product for real MSP tickets daily, then hands logins to 5 colleagues. +- **Solutions Library spec:** Written at `docs/plans/2026-03-23-solutions-library-design.md`, implementation deferred to post-pilot. --- ## What's Next (Priority Order) +### Phase O Cutover (Weeks 0-1) + +- Merge PR #164 +- Stripe Dashboard live-mode setup (Products + Prices for Starter/Pro, no Prices on Enterprise, Customer Portal config, webhook endpoint with 5 events) +- Railway prod env vars (`sk_live_*`, `whsec_*`, `INTERNAL_TESTER_EMAILS`, prod Google + Microsoft OAuth credentials, `OAUTH_REDIRECT_BASE`) +- Run `sync_stripe_plan_ids.py` against prod backend; verify `plan_billing` has `sk_live_*` price IDs +- Internal validation pass (9 scenarios from Phase O Task 46 plan) +- Email pilots about complimentary status, flip `SELF_SERVE_ENABLED=true` (frontend redeploy required for `VITE_SELF_SERVE_ENABLED`) +- PostHog dashboards + Sentry alert at >1/hour Stripe webhook errors + ### Pilot Phase (Weeks 1-2) - Founder dogfooding: use ResolutionFlow for real MSP tickets daily -- Collect feedback on copilot-first experience +- 3 calls with external Directors of Onboarding to validate the documentation-builder thesis (cold pitch, no friendly contacts) +- Collect feedback on copilot-first experience and self-serve onboarding flow - Fix issues discovered during real usage ### Post-Pilot (Weeks 3-4) diff --git a/README.md b/README.md index 80f38eff..ed266713 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ ```bash # Prerequisites: Docker, Python 3.12, Node.js 20+ -# Start PostgreSQL -docker start patherly_postgres +# Start PostgreSQL (and the rest of the dev stack) +docker compose -f docker-compose.dev.yml up -d # Backend cd backend @@ -105,16 +105,17 @@ Every session generates timestamped, detailed notes formatted for your PSA. Engi ## Project Structure ``` -patherly/ +resolutionflow/ ├── backend/ │ ├── app/ │ │ ├── main.py # FastAPI entry point -│ │ ├── api/endpoints/ # Route handlers (35+ endpoints) +│ │ ├── api/endpoints/ # Route handlers (50+ endpoints) │ │ ├── core/ # Config, database, permissions, security │ │ ├── models/ # SQLAlchemy models │ │ ├── schemas/ # Pydantic schemas │ │ └── services/psa/ # PSA provider abstraction layer │ ├── alembic/ # Database migrations +│ ├── scripts/ # Seed + sync scripts (incl. sync_stripe_plan_ids.py) │ └── tests/ # Integration tests (100+) ├── frontend/ │ ├── src/ @@ -122,13 +123,19 @@ patherly/ │ │ ├── pages/ # Page components │ │ ├── store/ # Zustand stores │ │ └── types/ # TypeScript interfaces +├── .ai/ # Dual-agent handoff system (PROJECT_CONTEXT, HANDOFF, etc.) ├── docs/ # Design docs, plans, ConnectWise reference ├── brand-assets/ # SVGs, brand guide -├── CLAUDE.md # AI assistant project context +├── CLAUDE.md # AI assistant project context (Claude Code) +├── AGENTS.md # AI assistant project context (Codex; shared protocol with CLAUDE.md) ├── CURRENT-STATE.md # Detailed feature status +├── DESIGN-SYSTEM.md # Visual + interaction design system +├── PRODUCT.md # Design intent and brand personality └── CHANGELOG.md # Release history ``` +> The on-disk repo path is `resolutionflow/`. `patherly` is the legacy internal name — still appears in some Railway service names and the prod DB name. Treat as an alias, not canonical. + --- ## Running Tests @@ -149,10 +156,13 @@ npm run build | Document | Purpose | |----------|---------| -| [CLAUDE.md](CLAUDE.md) | Full project context for AI-assisted development | +| [CLAUDE.md](CLAUDE.md) | Project context for Claude Code | +| [AGENTS.md](AGENTS.md) | Project context for Codex (shared protocol with CLAUDE.md) | +| [.ai/PROJECT_CONTEXT.md](.ai/PROJECT_CONTEXT.md) | Stable architectural truth | | [CURRENT-STATE.md](CURRENT-STATE.md) | Detailed feature status | | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) | Development roadmap | -| [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) | Design system (Slate & Ice) | +| [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) | Visual + interaction design system (charcoal palette + electric blue accent) | +| [PRODUCT.md](PRODUCT.md) | Design intent, users, brand personality | | [DEV-ENV.md](DEV-ENV.md) | Development environment setup | | [CHANGELOG.md](CHANGELOG.md) | Release history | -- 2.49.1 From 2c9f5e95ff0938fdb531f528dd23a4553660bfeb Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 8 May 2026 00:07:51 -0400 Subject: [PATCH 5/6] =?UTF-8?q?fix(frontend):=20page-title=20=E2=80=94=20e?= =?UTF-8?q?scapes=20+=20propagate=20plan=20taxonomy=20through=20frontend?= =?UTF-8?q?=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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