diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md index 131f3d63..ba506ec8 100644 --- a/.ai/HANDOFF.md +++ b/.ai/HANDOFF.md @@ -2,35 +2,35 @@ # HANDOFF.md -**Last updated:** 2026-05-02 (post-PR-159 — guides Diátaxis rewrite merged into `main`) +**Last updated:** 2026-05-06 (Phase 1 backend complete on `feat/self-serve-signup-spec`) -**Active task:** None. Pick next from `.ai/TODO.md` or `03-DEVELOPMENT-ROADMAP.md`. - -**Just-merged:** PR #159 — In-product User Guides rewritten as 43 Diátaxis how-tos under 10 categories. Drops 3 deprecated guides, renames Step Library → Solutions Library, fixes tip-markdown rendering, adds 14 net-new how-tos for FlowPilot-era surfaces. +**Active task:** Phase 1 self-serve signup backend foundation — DONE on branch. PR not yet opened. ## Where this session ended -PR #159 merged into `main`. CHANGELOG, CURRENT_TASK, SESSION_LOG all updated. See `.ai/CURRENT_TASK.md` "Recently shipped" for the structured rollup. +24 commits on top of `main` (`31ca3fb`). All 26 tasks from `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-1-backend.md` complete. Full pytest run is green (1167 passed, 35 deselected). Single alembic head: `c6cbfc534fad`. -The 43 guides live at `/guides` in the app. Schema is now category-aware (`Guide.category`, optional `relatedSlugs`); `categories` const drives hub ordering. Browser-verified against engineer + owner test users (sidebar labels, account sub-pages, pilot-screen header buttons, Tasks panel, integration form). tsc and Vite build clean. +Phase 1 covered: schema additions (oauth_identities, plan_billing, sales_leads, stripe_events, plus 5 new columns across users/accounts/account_invites), Subscription complimentary status + has_pro_entitlement, the two new guards (`require_active_subscription`, `require_verified_email_after_grace`), full BillingService (start_trial / create_checkout_session / apply_subscription_event / get_billing_state), Stripe webhook handler, Google + Microsoft OAuth callbacks with oauth_identities linking, OAuth-only password guard, register-time verification email + invite email-match, bulk + soft-revoke invite routes, GET /billing/state, and the pilot complimentary backfill migration. + +The conftest's `test_user` fixture was modified to seed a Pro/active Subscription post-register (delete-then-insert) so the new subscription guard doesn't 402 every existing test. Two existing tests adapted because they explicitly assumed the old free-plan default: `test_subscription_limits.py` (the two free-plan tests now downgrade inline) and `test_kb_accelerator.py::TestQuota::test_get_quota` (the `kb_setup` fixture downgrades to free). ## Resume point — DO THIS NEXT -The issue cleanup plan continues from before this session. Pick up `docs/plans/2026-05-01-issue-cleanup-plan.md` at section 3: **#58 structured "step is wrong" quality signals**. Then section 4 (#60 recurring issue detection) and section 5 (#129 hierarchical guide navigation). - -`$GITEA_TOKEN` is in `.claude/settings.local.json` — confirmed working via the PR-creation API call this session. Issue tracker actions can be done from the code-server LXC via `curl` against `https://gitea.resolutionflow.com/api/v1/...`. +1. Open the PR for branch `feat/self-serve-signup-spec`. Use `gh pr create` against `main`. Suggested title: `feat: self-serve signup backend (Phase 1)`. Body should mention dark-launch posture (every new endpoint is gated by env config, not a feature flag — see Task 26 §3 in the plan). +2. Phase 2 (frontend + cutover) lives in a sibling plan: `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend.md` (assumed; verify path). It's the next logical task once Phase 1 ships. ## Followups deferred from this session -Worth picking up if a related touch happens: - -- **`change-teammate-role` how-to was dropped** from PR #159 because the test owner account has no non-owner members to inspect the role-change control. Once a teammate is invited via the Membership form on `/account`, verify whether the list exposes a Role dropdown (or some other control) for non-owners and add the guide back to `frontend/src/data/guides.ts` under the `account-admin` category. -- **Resolve / Escalate modal contents are unverified.** Browser couldn't drive Resolve to completion (test session has 6 pending Tasks gating it; clicking Resolve fired a toast). The how-tos point at the right buttons in the right place, but the exact modal copy and the Escalation Mode wedge specifics are based on project context, not live observation. Worth a quick spot-check the next time a clean test session is available. +- **OAuth callbacks don't call `_store_refresh_token`.** The Google/Microsoft callbacks issue a refresh JWT but never persist its hash to `refresh_tokens` (the password-login flow does via `auth.py:_store_refresh_token`). Result: refresh-token revocation/rotation lookups won't find OAuth-issued tokens. Decide before Phase 2 dark-launch whether to backfill — likely yes, by extracting `_store_refresh_token` to a shared module and calling it from `_sign_in_or_register`. +- **`stripe_enabled` was relaxed** in Task 14 from `bool(STRIPE_SECRET_KEY) and bool(STRIPE_WEBHOOK_SECRET)` to just the secret key. The webhook handler in Task 16 independently checks `STRIPE_WEBHOOK_SECRET` before calling `construct_event`, so signature verification is still safe — but if any other code reads `stripe_enabled` and assumes the webhook secret is set, that's a latent bug. Audit before Phase 2 cutover. +- **`backend/app/core/stripe_handlers.py`** is a stub module that's no longer referenced after Task 16. Safe to delete in a follow-up; left in place to keep Phase 1 diff focused. +- **Pilot backfill migration `c6cbfc534fad` has not been applied to prod yet.** It runs once at deploy time and is forward-only. ## Environment notes (carry-forward) -- Code-server LXC has bun + docker but no native `python`/`node`/`npm`. Use `docker exec resolutionflow_{backend,frontend} …` for build/test commands. -- No `gh` CLI on this LXC — use the Gitea API (`$GITEA_TOKEN`) for PR/issue work, or run `gh` from a host that has it. +- Code-server LXC has bun + docker but no native python/node/npm. Use `docker exec resolutionflow_{backend,frontend} ...` for build/test commands. +- Pytest WORKDIR is `/app` — test paths in pytest commands are `tests/`, NOT `backend/tests/`. +- Backend pytest cmd: `docker exec resolutionflow_backend pytest tests/ -v --override-ini="addopts="`. The full run takes ~25 min. +- Alembic via `docker exec -w /app resolutionflow_backend alembic ...`. Never pass `--rev-id`. +- No `gh` CLI on this LXC — use the Gitea API (`$GITEA_TOKEN` in `.claude/settings.local.json`) for PR/issue work, or run `gh` from a host that has it. - Headless Chromium (`/qa`, `/browse`) needs `CONTAINER=1` in the env launching the browse server (LXC namespace constraint). -- `/etc/hosts` has `100.64.78.44 docker-01` so the headless browser resolves the bake-in `VITE_API_URL`. The previous handoff claimed this entry was persistent but it was missing on this LXC at the start of this session — re-added via `sudo tee` from a real terminal (the `!` shell prefix can't drive interactive sudo). Confirmed working. -- Multi-head alembic state on `main` (heads `070`, `c0f3a4b7e91d`, `024`) is pre-existing. Use `alembic upgrade heads` (plural) if `head` complains. diff --git a/backend/alembic/versions/2aa73d3231c2_account_invites_add_revoked_at_and_.py b/backend/alembic/versions/2aa73d3231c2_account_invites_add_revoked_at_and_.py new file mode 100644 index 00000000..db4f258f --- /dev/null +++ b/backend/alembic/versions/2aa73d3231c2_account_invites_add_revoked_at_and_.py @@ -0,0 +1,30 @@ +"""account_invites add revoked_at and email_sent_at + +Revision ID: 2aa73d3231c2 +Revises: e1af7ab57ceb +Create Date: 2026-05-06 07:28:28.514384 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2aa73d3231c2' +down_revision: Union[str, None] = 'e1af7ab57ceb' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("account_invites", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("account_invites", sa.Column("email_sent_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index("ix_account_invites_revoked_at", "account_invites", ["revoked_at"]) + + +def downgrade() -> None: + op.drop_index("ix_account_invites_revoked_at", table_name="account_invites") + op.drop_column("account_invites", "email_sent_at") + op.drop_column("account_invites", "revoked_at") diff --git a/backend/alembic/versions/58e3caaa6269_users_add_role_at_signup_and_onboarding_.py b/backend/alembic/versions/58e3caaa6269_users_add_role_at_signup_and_onboarding_.py new file mode 100644 index 00000000..5958e708 --- /dev/null +++ b/backend/alembic/versions/58e3caaa6269_users_add_role_at_signup_and_onboarding_.py @@ -0,0 +1,28 @@ +"""users add role_at_signup and onboarding_step_completed + +Revision ID: 58e3caaa6269 +Revises: 5bb055a1593e +Create Date: 2026-05-06 07:25:16.780761 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '58e3caaa6269' +down_revision: Union[str, None] = '5bb055a1593e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("role_at_signup", sa.String(50), nullable=True)) + op.add_column("users", sa.Column("onboarding_step_completed", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "onboarding_step_completed") + op.drop_column("users", "role_at_signup") diff --git a/backend/alembic/versions/5bb055a1593e_users_password_hash_nullable.py b/backend/alembic/versions/5bb055a1593e_users_password_hash_nullable.py new file mode 100644 index 00000000..209baaec --- /dev/null +++ b/backend/alembic/versions/5bb055a1593e_users_password_hash_nullable.py @@ -0,0 +1,47 @@ +"""users password_hash nullable + +Revision ID: 5bb055a1593e +Revises: b1fad5ddf357 +Create Date: 2026-05-06 07:23:21.480252 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5bb055a1593e' +down_revision: Union[str, None] = 'b1fad5ddf357' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column( + "users", + "password_hash", + existing_type=sa.String(255), + nullable=True, + ) + + +def downgrade() -> None: + # NOTE: downgrade is non-trivial if any OAuth-only users exist. + # This downgrade fails fast in that case rather than corrupting data. + conn = op.get_bind() + null_count = conn.execute( + sa.text("SELECT COUNT(*) FROM users WHERE password_hash IS NULL") + ).scalar() + if null_count and null_count > 0: + raise RuntimeError( + f"Cannot downgrade: {null_count} OAuth-only users have NULL password_hash. " + "Set passwords or delete those rows before downgrading." + ) + op.alter_column( + "users", + "password_hash", + existing_type=sa.String(255), + nullable=False, + ) diff --git a/backend/alembic/versions/b1fad5ddf357_add_oauth_identities.py b/backend/alembic/versions/b1fad5ddf357_add_oauth_identities.py new file mode 100644 index 00000000..da4242c7 --- /dev/null +++ b/backend/alembic/versions/b1fad5ddf357_add_oauth_identities.py @@ -0,0 +1,39 @@ +"""add oauth_identities + +Revision ID: b1fad5ddf357 +Revises: c0f3a4b7e91d +Create Date: 2026-05-06 07:17:11.374555 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = 'b1fad5ddf357' +down_revision: Union[str, None] = 'c0f3a4b7e91d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "oauth_identities", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("provider", sa.String(20), nullable=False), + sa.Column("provider_subject", sa.String(255), nullable=False), + sa.Column("provider_email_at_link", sa.String(255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("provider", "provider_subject", name="uq_oauth_identities_provider_subject"), + ) + op.create_index("ix_oauth_identities_user_id", "oauth_identities", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_oauth_identities_user_id", table_name="oauth_identities") + op.drop_table("oauth_identities") diff --git a/backend/alembic/versions/c6cbfc534fad_subscriptions_pilot_complimentary_.py b/backend/alembic/versions/c6cbfc534fad_subscriptions_pilot_complimentary_.py new file mode 100644 index 00000000..647fd085 --- /dev/null +++ b/backend/alembic/versions/c6cbfc534fad_subscriptions_pilot_complimentary_.py @@ -0,0 +1,47 @@ +"""subscriptions pilot complimentary backfill + +This migration converts existing pilot/dev accounts to permanent complimentary +Pro per the self-serve signup spec section 5. Forward-only; downgrade is +prohibited because original status is not preserved. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "c6cbfc534fad" +down_revision: Union[str, None] = "c982a3fc4bf1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Set status='complimentary' and plan='pro' for all existing accounts that + don't have a canceled or past_due subscription. Pilot users transition to + permanent complimentary Pro per spec section 5. + + Forward-only — does not preserve original status values.""" + conn = op.get_bind() + # Update existing rows + conn.execute(sa.text(""" + UPDATE subscriptions + SET status = 'complimentary', plan = 'pro', + current_period_end = NULL, current_period_start = NULL, + updated_at = now() + WHERE status NOT IN ('canceled', 'past_due') + """)) + # Backfill: any account without a Subscription row gets one + conn.execute(sa.text(""" + INSERT INTO subscriptions (id, account_id, plan, status, cancel_at_period_end, created_at, updated_at) + SELECT gen_random_uuid(), a.id, 'pro', 'complimentary', false, now(), now() + FROM accounts a + WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.account_id = a.id) + """)) + + +def downgrade() -> None: + raise RuntimeError( + "Cannot downgrade: original subscription state is not preserved. " + "Restore from backup if needed." + ) diff --git a/backend/alembic/versions/c982a3fc4bf1_add_stripe_events.py b/backend/alembic/versions/c982a3fc4bf1_add_stripe_events.py new file mode 100644 index 00000000..6deb419a --- /dev/null +++ b/backend/alembic/versions/c982a3fc4bf1_add_stripe_events.py @@ -0,0 +1,45 @@ +"""add stripe_events + +Revision ID: c982a3fc4bf1 +Revises: f7da3f93b519 +Create Date: 2026-05-06 07:32:08.027633 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +# revision identifiers, used by Alembic. +revision: str = 'c982a3fc4bf1' +down_revision: Union[str, None] = 'f7da3f93b519' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "stripe_events", + sa.Column("id", sa.String(length=255), primary_key=True, nullable=False), + sa.Column("event_type", sa.String(length=100), nullable=False), + sa.Column( + "processed_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "payload_excerpt", + JSONB, + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + ) + op.create_index("ix_stripe_events_event_type", "stripe_events", ["event_type"]) + + +def downgrade() -> None: + op.drop_index("ix_stripe_events_event_type", table_name="stripe_events") + op.drop_table("stripe_events") diff --git a/backend/alembic/versions/e1af7ab57ceb_accounts_add_wizard_columns.py b/backend/alembic/versions/e1af7ab57ceb_accounts_add_wizard_columns.py new file mode 100644 index 00000000..1f676fd6 --- /dev/null +++ b/backend/alembic/versions/e1af7ab57ceb_accounts_add_wizard_columns.py @@ -0,0 +1,28 @@ +"""accounts add wizard columns + +Revision ID: e1af7ab57ceb +Revises: 58e3caaa6269 +Create Date: 2026-05-06 07:27:15.755518 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e1af7ab57ceb' +down_revision: Union[str, None] = '58e3caaa6269' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("accounts", sa.Column("team_size_bucket", sa.String(20), nullable=True)) + op.add_column("accounts", sa.Column("primary_psa", sa.String(20), nullable=True)) + + +def downgrade() -> None: + op.drop_column("accounts", "primary_psa") + op.drop_column("accounts", "team_size_bucket") diff --git a/backend/alembic/versions/f236a91224d0_add_plan_billing.py b/backend/alembic/versions/f236a91224d0_add_plan_billing.py new file mode 100644 index 00000000..0cef8aa9 --- /dev/null +++ b/backend/alembic/versions/f236a91224d0_add_plan_billing.py @@ -0,0 +1,41 @@ +"""add plan_billing + +Revision ID: f236a91224d0 +Revises: 2aa73d3231c2 +Create Date: 2026-05-06 07:30:06.807887 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f236a91224d0' +down_revision: Union[str, None] = '2aa73d3231c2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "plan_billing", + sa.Column("plan", sa.String(50), sa.ForeignKey("plan_limits.plan"), primary_key=True), + sa.Column("display_name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("monthly_price_cents", sa.Integer(), nullable=True), + sa.Column("annual_price_cents", sa.Integer(), nullable=True), + sa.Column("stripe_product_id", sa.String(255), nullable=True), + sa.Column("stripe_monthly_price_id", sa.String(255), nullable=True), + sa.Column("stripe_annual_price_id", sa.String(255), nullable=True), + sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("plan_billing") diff --git a/backend/alembic/versions/f7da3f93b519_add_sales_leads.py b/backend/alembic/versions/f7da3f93b519_add_sales_leads.py new file mode 100644 index 00000000..154a4db0 --- /dev/null +++ b/backend/alembic/versions/f7da3f93b519_add_sales_leads.py @@ -0,0 +1,57 @@ +"""add sales_leads + +Revision ID: f7da3f93b519 +Revises: f236a91224d0 +Create Date: 2026-05-06 07:31:39.533305 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = 'f7da3f93b519' +down_revision: Union[str, None] = 'f236a91224d0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "sales_leads", + sa.Column("id", UUID(as_uuid=True), primary_key=True, nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("company", sa.String(length=255), nullable=False), + sa.Column("team_size", sa.String(length=20), nullable=True), + sa.Column("message", sa.Text(), nullable=True), + sa.Column("source", sa.String(length=50), nullable=False), + sa.Column("posthog_distinct_id", sa.String(length=255), nullable=True), + sa.Column( + "status", + sa.String(length=20), + nullable=False, + server_default=sa.text("'new'"), + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + op.create_index("ix_sales_leads_email", "sales_leads", ["email"]) + + +def downgrade() -> None: + op.drop_index("ix_sales_leads_email", table_name="sales_leads") + op.drop_table("sales_leads") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 79770ed9..9fbd5815 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -83,11 +83,12 @@ async def get_current_active_user( current_user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_admin_db)], ) -> User: - """Ensure user is active (not disabled). Auto-downgrades expired trials. - Enforces must_change_password — blocks all routes except allowlist. + """Ensure user is active (not disabled). Enforces must_change_password — + blocks all routes except allowlist. - Uses get_admin_db: runs before require_tenant_context sets the ContextVar, - so tenant-scoped tables (subscriptions) would return 0 rows via app role. + Trial expiry enforcement now happens via require_active_subscription in + individual routers, NOT here. This dep no longer mutates Subscription + state. """ if not current_user.is_active: raise HTTPException( @@ -106,26 +107,6 @@ async def get_current_active_user( # Set Sentry user context for error attribution sentry_sdk.set_user({"id": str(current_user.id), "email": current_user.email}) - # Lightweight trial expiry check - if current_user.account_id: - from app.models.subscription import Subscription - from datetime import datetime, timezone - result = await db.execute( - select(Subscription).where(Subscription.account_id == current_user.account_id) - ) - subscription = result.scalar_one_or_none() - if ( - subscription - and subscription.status == "trialing" - and subscription.current_period_end - and subscription.current_period_end < datetime.now(timezone.utc) - ): - subscription.plan = "free" - subscription.status = "active" - subscription.current_period_end = None - subscription.current_period_start = None - await db.commit() - return current_user @@ -241,3 +222,114 @@ async def require_admin_db( the user object is needed in the handler. """ return db + + +_SUBSCRIPTION_GUARD_ALLOWLIST = { + "/api/v1/auth/me", + "/api/v1/auth/logout", + "/api/v1/auth/password/change", + "/api/v1/auth/email/send-verification", + "/api/v1/auth/email/verify", + "/api/v1/billing/state", + "/api/v1/billing/checkout-session", + "/api/v1/billing/portal-session", + "/api/v1/users/me", + "/api/v1/users/me/onboarding-step", +} + + +async def require_active_subscription( + request: Request, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_admin_db)], +): + """Returns the Subscription row when the account has access; raises 402 + when locked. Mounted on routers requiring Pro entitlement. + + 'Locked' = (trialing AND current_period_end < now()) OR + (canceled OR incomplete OR no subscription). + Active states: active, complimentary, trialing-with-time-remaining, past_due. + """ + if request.url.path in _SUBSCRIPTION_GUARD_ALLOWLIST: + return None + + from app.models.subscription import Subscription + from datetime import datetime, timezone + + result = await db.execute( + select(Subscription).where(Subscription.account_id == current_user.account_id) + ) + sub = result.scalar_one_or_none() + + if sub is None: + raise HTTPException( + status_code=402, + detail={"error": "no_subscription", "upgrade_url": "/account/billing/select-plan"}, + ) + + now = datetime.now(timezone.utc) + is_live = ( + sub.status in ("active", "complimentary", "past_due") + or ( + sub.status == "trialing" + and sub.current_period_end is not None + and sub.current_period_end > now + ) + ) + if not is_live: + raise HTTPException( + status_code=402, + detail={ + "error": "subscription_inactive", + "status": sub.status, + "plan": sub.plan, + "current_period_end": sub.current_period_end.isoformat() if sub.current_period_end else None, + "upgrade_url": "/account/billing/select-plan", + }, + ) + + return sub + + +_EMAIL_VERIFICATION_ALLOWLIST = { + "/api/v1/auth/me", + "/api/v1/auth/logout", + "/api/v1/auth/email/send-verification", + "/api/v1/auth/email/verify", + "/api/v1/auth/password/change", + "/api/v1/users/me", + "/api/v1/billing/state", + "/api/v1/billing/checkout-session", + "/api/v1/billing/portal-session", +} + +VERIFICATION_GRACE_DAYS = 7 + + +async def require_verified_email_after_grace( + request: Request, + current_user: Annotated[User, Depends(get_current_active_user)], +): + """Enforces 'this user has verified email OR is still in 7-day grace.' + OAuth signups bypass cleanly because /auth/{google,microsoft}/callback + sets users.email_verified_at = now() (provider-attested).""" + from datetime import datetime, timezone, timedelta + + if request.url.path in _EMAIL_VERIFICATION_ALLOWLIST: + return + + if current_user.email_verified_at is not None: + return + + grace_ends = current_user.created_at + timedelta(days=VERIFICATION_GRACE_DAYS) + if datetime.now(timezone.utc) < grace_ends: + return + + raise HTTPException( + status_code=403, + detail={ + "error": "email_not_verified", + "grace_ended_at": grace_ends.isoformat(), + "resend_url": "/api/v1/auth/email/send-verification", + }, + ) diff --git a/backend/app/api/endpoints/accounts.py b/backend/app/api/endpoints/accounts.py index b7910ca2..6b3fab83 100644 --- a/backend/app/api/endpoints/accounts.py +++ b/backend/app/api/endpoints/accounts.py @@ -19,7 +19,7 @@ from app.models.account_invite import AccountInvite from app.models.account_settings import AccountSettings from app.models.subscription import Subscription from app.models.user import User -from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, TransferOwnershipRequest +from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, AccountInviteBulkCreate, AccountInviteBulkResponse, TransferOwnershipRequest from app.schemas.subscription import SubscriptionResponse, PlanLimitsResponse, UsageResponse, SubscriptionDetails from app.schemas.user import UserResponse, AccountRoleUpdate from app.core.security import verify_password @@ -260,7 +260,7 @@ async def create_invite( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_account_owner)] ): - """Create an invite to join this account (owner only).""" + """Create an invite to join this account (owner only). Sends invite email.""" code = secrets.token_urlsafe(16) expires_at = None @@ -276,11 +276,109 @@ async def create_invite( expires_at=expires_at, ) db.add(invite) + await db.flush() + + # Lookup account name for email + account_result = await db.execute( + select(Account).where(Account.id == current_user.account_id) + ) + account = account_result.scalar_one() + + # Send invite email — non-blocking on failure (function returns False on error) + email_sent = await EmailService.send_account_invite_email( + to_email=invite.email, + code=code, + account_name=account.name, + role=invite.role, + ) + if email_sent: + invite.email_sent_at = datetime.now(timezone.utc) + await db.commit() await db.refresh(invite) return invite +@router.post("/me/invites/bulk", response_model=AccountInviteBulkResponse, status_code=status.HTTP_201_CREATED) +async def create_invites_bulk( + payload: AccountInviteBulkCreate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_account_owner)] +): + """Create multiple invites in one call (wizard step 3 supports up to N). + Per-row failures are returned in `failed`; successes in `created`.""" + # Lookup account once for email rendering + account_result = await db.execute( + select(Account).where(Account.id == current_user.account_id) + ) + account = account_result.scalar_one() + + created: list[AccountInvite] = [] + failed: list[dict] = [] + for invite_data in payload.invites: + try: + code = secrets.token_urlsafe(16) + expires_at = None + if invite_data.expires_in_days: + expires_at = datetime.now(timezone.utc) + timedelta(days=invite_data.expires_in_days) + + invite = AccountInvite( + account_id=current_user.account_id, + invited_by_id=current_user.id, + email=invite_data.email, + code=code, + role=invite_data.role, + expires_at=expires_at, + ) + db.add(invite) + await db.flush() + + email_sent = await EmailService.send_account_invite_email( + to_email=invite.email, + code=code, + account_name=account.name, + role=invite.role, + ) + if email_sent: + invite.email_sent_at = datetime.now(timezone.utc) + + created.append(invite) + except Exception as e: + failed.append({"email": invite_data.email, "error": str(e)}) + + await db.commit() + for inv in created: + await db.refresh(inv) + + return AccountInviteBulkResponse(created=created, failed=failed) + + +@router.delete("/me/invites/{invite_id}", status_code=status.HTTP_204_NO_CONTENT) +async def revoke_invite( + invite_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_account_owner)] +): + """Soft-revoke an invitation by setting revoked_at. Idempotent on already- + revoked invites; rejects already-accepted invites.""" + result = await db.execute( + select(AccountInvite).where( + AccountInvite.id == invite_id, + AccountInvite.account_id == current_user.account_id, + ) + ) + invite = result.scalar_one_or_none() + if not invite: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found") + if invite.is_revoked: + return None # idempotent + if invite.is_used: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot revoke an accepted invite") + invite.revoked_at = datetime.now(timezone.utc) + await db.commit() + return None + + @router.post("/me/invites/{invite_id}/resend", response_model=AccountInviteResponse) async def resend_invite( invite_id: UUID, diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 2634a6ef..44507328 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -1,3 +1,4 @@ +import logging import secrets import string from datetime import datetime, timezone, timedelta @@ -41,6 +42,8 @@ from app.core.email import EmailService from app.api.deps import get_current_active_user, get_refresh_token_payload from app.core.audit import log_audit +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/auth", tags=["authentication"]) @@ -62,6 +65,22 @@ def _generate_display_code() -> str: return ''.join(secrets.choice(chars) for _ in range(8)) +async def _reject_if_oauth_only(db: AsyncSession, user) -> None: + """If the user has no password_hash, raise 400 with a list of linked + providers so the client can redirect them to the right OAuth flow.""" + if user is None or user.password_hash is not None: + return + from app.models.oauth_identity import OAuthIdentity + result = await db.execute( + select(OAuthIdentity.provider).where(OAuthIdentity.user_id == user.id) + ) + providers = [row for row in result.scalars().all()] + raise HTTPException( + status_code=400, + detail={"error": "use_oauth_provider", "providers": providers}, + ) + + @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) @limiter.limit("3/minute") async def register( @@ -108,6 +127,12 @@ async def register( detail="Account invite code has expired" ) + if account_invite_record.email.lower() != user_data.email.lower(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "invite_email_mismatch"}, + ) + # Validate platform invite code (skip if account invite was provided) invite_code_record = None if not account_invite_record: @@ -195,26 +220,30 @@ async def register( # Now set account owner and create subscription new_account.owner_id = new_user.id - # Apply plan/trial from invite code if present - sub_plan = "free" - sub_status = "active" - period_start = None - period_end = None if invite_code_record and invite_code_record.assigned_plan: + # Plan/trial driven by platform invite code (existing pilot flow) sub_plan = invite_code_record.assigned_plan + sub_status = "active" + period_start = None + period_end = None if invite_code_record.trial_duration_days: sub_status = "trialing" period_start = datetime.now(timezone.utc) period_end = period_start + timedelta(days=invite_code_record.trial_duration_days) - - new_subscription = Subscription( - account_id=new_account.id, - plan=sub_plan, - status=sub_status, - current_period_start=period_start, - current_period_end=period_end, - ) - db.add(new_subscription) + db.add(Subscription( + account_id=new_account.id, + plan=sub_plan, + status=sub_status, + current_period_start=period_start, + current_period_end=period_end, + )) + else: + # New self-serve shop — start the standard Pro trial. + # start_trial commits internally; flush our pending User/Account changes + # first so the FK is satisfied. + await db.flush() + from app.services.billing import BillingService + await BillingService.start_trial(db, new_account.id) # Mark platform invite code as used if invite_code_record: @@ -224,6 +253,34 @@ async def register( await db.commit() await db.refresh(new_user) + # Auto-send verification email for newly-registered users. + # Skip silently if verification already done (shouldn't happen for fresh + # users, but defensive). + if new_user.email_verified_at is None: + verification_enabled = await SettingsManager.get( + "email_verification_enabled", db, default=True + ) + if verification_enabled: + try: + raw_token = create_email_verification_token(str(new_user.id)) + payload = decode_token(raw_token) + if payload and payload.get("jti"): + token_record = EmailVerificationToken( + token_hash=hash_token(payload["jti"]), + user_id=new_user.id, + expires_at=datetime.fromtimestamp(payload["exp"], tz=timezone.utc), + ) + db.add(token_record) + await db.commit() + + verification_url = f"{settings.FRONTEND_URL}/verify-email?token={raw_token}" + await EmailService.send_email_verification_email( + to_email=new_user.email, + verification_url=verification_url, + ) + except Exception as e: + logger.warning("verification email send failed for %s: %s", new_user.email, e) + return new_user @@ -239,6 +296,7 @@ async def login( result = await db.execute(select(User).where(User.email == form_data.username)) user = result.scalar_one_or_none() + await _reject_if_oauth_only(db, user) if not user or not verify_password(form_data.password, user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -276,6 +334,7 @@ async def login_json( result = await db.execute(select(User).where(User.email == credentials.email)) user = result.scalar_one_or_none() + await _reject_if_oauth_only(db, user) if not user or not verify_password(credentials.password, user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -441,6 +500,7 @@ async def change_password( db: Annotated[AsyncSession, Depends(get_admin_db)] ): """Change the current user's password.""" + await _reject_if_oauth_only(db, current_user) if not verify_password(data.current_password, current_user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -484,7 +544,7 @@ async def forgot_password( result = await db.execute(select(User).where(User.email == data.email)) user = result.scalar_one_or_none() - if user: + if user and user.password_hash is not None: # Create reset token JWT raw_token = create_password_reset_token(str(user.id)) payload = decode_token(raw_token) diff --git a/backend/app/api/endpoints/billing.py b/backend/app/api/endpoints/billing.py new file mode 100644 index 00000000..23d067d4 --- /dev/null +++ b/backend/app/api/endpoints/billing.py @@ -0,0 +1,52 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user +from app.core.admin_database import get_admin_db +from app.core.config import settings +from app.models.account import Account +from app.models.user import User +from app.schemas.billing import ( + BillingStateResponse, + CheckoutSessionCreate, + CheckoutSessionResponse, +) +from app.services.billing import BillingService + +router = APIRouter(prefix="/billing", tags=["billing"]) + + +@router.post("/checkout-session", response_model=CheckoutSessionResponse) +async def create_checkout_session( + payload: CheckoutSessionCreate, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> CheckoutSessionResponse: + account = (await db.execute( + select(Account).where(Account.id == current_user.account_id) + )).scalar_one() + url = await BillingService.create_checkout_session( + db=db, + account=account, + plan=payload.plan, + seats=payload.seats, + billing_interval=payload.billing_interval, + success_url=f"{settings.FRONTEND_URL}/account/billing?success=1", + cancel_url=f"{settings.FRONTEND_URL}/account/billing/select-plan", + ) + return CheckoutSessionResponse(url=url) + + +@router.get("/state", response_model=BillingStateResponse) +async def get_billing_state( + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> BillingStateResponse: + account = (await db.execute( + select(Account).where(Account.id == current_user.account_id) + )).scalar_one() + state = await BillingService.get_billing_state(db, account) + return BillingStateResponse(**state) diff --git a/backend/app/api/endpoints/oauth.py b/backend/app/api/endpoints/oauth.py new file mode 100644 index 00000000..cbc5aedf --- /dev/null +++ b/backend/app/api/endpoints/oauth.py @@ -0,0 +1,123 @@ +import secrets +import string +from datetime import datetime, timezone +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.admin_database import get_admin_db +from app.core.config import settings +from app.core.security import create_access_token, create_refresh_token +from app.models.account import Account +from app.models.oauth_identity import OAuthIdentity +from app.models.user import User +from app.schemas.oauth import OAuthCallbackPayload, OAuthCallbackResponse +from app.services.billing import BillingService +from app.services.oauth_providers import ( + google_exchange_code, + microsoft_exchange_code, + OAuthProfile, +) + +router = APIRouter(prefix="/auth", tags=["auth-oauth"]) + + +def _generate_display_code(length: int = 8) -> str: + """Match the helper used by /auth/register — A-Z + 0-9, length 8.""" + alphabet = string.ascii_uppercase + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +async def _sign_in_or_register( + db: AsyncSession, provider: str, profile: OAuthProfile +) -> tuple[User, bool]: + """Returns (user, is_new_user). Idempotent on (provider, provider_subject).""" + identity = ( + await db.execute( + select(OAuthIdentity).where( + OAuthIdentity.provider == provider, + OAuthIdentity.provider_subject == profile.provider_subject, + ) + ) + ).scalar_one_or_none() + + if identity: + user = ( + await db.execute(select(User).where(User.id == identity.user_id)) + ).scalar_one() + return user, False + + user = ( + await db.execute(select(User).where(User.email == profile.email)) + ).scalar_one_or_none() + is_new_user = user is None + if is_new_user: + account = Account( + name=f"{profile.name}'s Account", + display_code=_generate_display_code(), + ) + db.add(account) + await db.flush() + user = User( + email=profile.email, + name=profile.name, + password_hash=None, + account_id=account.id, + account_role="owner", + role="engineer", + email_verified_at=datetime.now(timezone.utc), + ) + db.add(user) + await db.flush() + account.owner_id = user.id + await db.flush() + # start_trial commits internally; flushed account/user above. + await BillingService.start_trial(db, account.id) + + db.add( + OAuthIdentity( + user_id=user.id, + provider=provider, + provider_subject=profile.provider_subject, + provider_email_at_link=profile.email, + ) + ) + await db.commit() + await db.refresh(user) + return user, is_new_user + + +@router.post("/google/callback", response_model=OAuthCallbackResponse) +async def google_callback( + payload: OAuthCallbackPayload, + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> OAuthCallbackResponse: + if not settings.GOOGLE_CLIENT_ID: + raise HTTPException(status_code=503, detail="Google sign-in not configured") + redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/google/callback" + profile = await google_exchange_code(payload.code, redirect_uri) + user, is_new = await _sign_in_or_register(db, "google", profile) + return OAuthCallbackResponse( + access_token=create_access_token({"sub": str(user.id)}), + refresh_token=create_refresh_token({"sub": str(user.id)}), + is_new_user=is_new, + ) + + +@router.post("/microsoft/callback", response_model=OAuthCallbackResponse) +async def microsoft_callback( + payload: OAuthCallbackPayload, + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> OAuthCallbackResponse: + if not settings.MS_CLIENT_ID: + raise HTTPException(status_code=503, detail="Microsoft sign-in not configured") + redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/microsoft/callback" + profile = await microsoft_exchange_code(payload.code, redirect_uri) + user, is_new = await _sign_in_or_register(db, "microsoft", profile) + return OAuthCallbackResponse( + access_token=create_access_token({"sub": str(user.id)}), + refresh_token=create_refresh_token({"sub": str(user.id)}), + is_new_user=is_new, + ) diff --git a/backend/app/api/endpoints/webhooks.py b/backend/app/api/endpoints/webhooks.py index 1773ec22..d266f468 100644 --- a/backend/app/api/endpoints/webhooks.py +++ b/backend/app/api/endpoints/webhooks.py @@ -1,10 +1,10 @@ import logging -from fastapi import APIRouter, Request, HTTPException, status, Depends +from fastapi import APIRouter, Request, HTTPException, Depends from sqlalchemy.ext.asyncio import AsyncSession -from app.core.database import get_db +from app.core.admin_database import get_admin_db from app.core.config import settings -from app.core.stripe_handlers import WEBHOOK_HANDLERS +from app.services.billing import BillingService logger = logging.getLogger(__name__) @@ -14,49 +14,36 @@ router = APIRouter(prefix="/webhooks", tags=["webhooks"]) @router.post("/stripe") async def stripe_webhook( request: Request, - db: AsyncSession = Depends(get_db), + db: AsyncSession = Depends(get_admin_db), ): - """Handle Stripe webhook events. + """Stripe webhook handler. Public endpoint; signature verification is the + only gate. Idempotency via stripe_events table. - Returns 200 for all events to prevent Stripe retries. - Actual processing happens only when Stripe is configured. + Returns 200 even when Stripe is not configured — keeps the receiver + permissive for local dev. """ - if not settings.stripe_enabled: + if not settings.stripe_enabled or not settings.STRIPE_WEBHOOK_SECRET: return {"status": "ok", "message": "Stripe not configured, event ignored"} payload = await request.body() sig_header = request.headers.get("stripe-signature") - if not sig_header: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Missing stripe-signature header" - ) + raise HTTPException(status_code=400, detail="Missing stripe-signature header") - # Verify webhook signature try: import stripe stripe.api_key = settings.STRIPE_SECRET_KEY event = stripe.Webhook.construct_event( payload, sig_header, settings.STRIPE_WEBHOOK_SECRET ) - except ImportError: - logger.warning("stripe package not installed, cannot verify webhook") - return {"status": "ok", "message": "stripe package not installed"} except Exception as e: - logger.error("Stripe webhook signature verification failed: %s", e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid signature" - ) + logger.warning("stripe webhook bad signature: %s", e) + raise HTTPException(status_code=400, detail="Invalid signature") - event_type = event.get("type", "") - handler = WEBHOOK_HANDLERS.get(event_type) - - if handler: - try: - await handler(event, db) - except Exception: - logger.exception("Error handling Stripe event %s", event_type) - - return {"status": "ok"} + applied = await BillingService.apply_subscription_event( + db, + event_id=event["id"], + event_type=event["type"], + payload={"data": event["data"]}, + ) + return {"status": "ok", "applied": applied} diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 5a51bdd9..01ce9a8a 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,6 +1,10 @@ from fastapi import APIRouter, Depends -from app.api.deps import require_tenant_context +from app.api.deps import ( + require_tenant_context, + require_active_subscription, + require_verified_email_after_grace, +) from app.api.endpoints import ( admin, admin_audit, @@ -19,6 +23,7 @@ from app.api.endpoints import ( analytics, assistant_chat, auth, + billing, beta_feedback, beta_signup, branding, @@ -36,6 +41,7 @@ from app.api.endpoints import ( maintenance_schedules, network_diagrams, notifications, + oauth as oauth_endpoints, onboarding, public_templates, ratings, @@ -77,6 +83,8 @@ api_router = APIRouter() # in Phase 1. This will need revisiting in Phase 2 when `users` gets RLS. # --------------------------------------------------------------------------- api_router.include_router(auth.router) +api_router.include_router(oauth_endpoints.router) +api_router.include_router(billing.router) # Reachable when subscription locked api_router.include_router(shared.router) # Public share links (no auth) api_router.include_router(shares.public_router) # Public session share links (optional auth) api_router.include_router(beta_signup.router) @@ -102,23 +110,36 @@ api_router.include_router(admin_survey.router) api_router.include_router(admin_gallery.router) # --------------------------------------------------------------------------- # User-facing endpoints — tenant context required +# +# _tenant_deps: routers that only require an authenticated user inside a +# tenant (auth/account/admin/non-Pro feature surfaces). +# _pro_deps: routers gated behind an active Pro subscription. Adds +# require_active_subscription which raises 402 unless the +# account's Subscription is active/complimentary/past_due or +# trialing-with-time-remaining. Allowlisted paths in deps.py +# bypass the gate for billing/account admin/auth flows. # --------------------------------------------------------------------------- _tenant_deps = [Depends(require_tenant_context)] +_pro_deps = [ + Depends(require_tenant_context), + Depends(require_active_subscription), + Depends(require_verified_email_after_grace), +] -api_router.include_router(trees.router, dependencies=_tenant_deps) +api_router.include_router(trees.router, dependencies=_pro_deps) api_router.include_router(sidebar.router, dependencies=_tenant_deps) -api_router.include_router(sessions.router, dependencies=_tenant_deps) +api_router.include_router(sessions.router, dependencies=_pro_deps) api_router.include_router(invite.router, dependencies=_tenant_deps) api_router.include_router(categories.router, dependencies=_tenant_deps) api_router.include_router(tags.router, dependencies=_tenant_deps) api_router.include_router(folders.router, dependencies=_tenant_deps) -api_router.include_router(step_categories.router, dependencies=_tenant_deps) -api_router.include_router(steps.router, dependencies=_tenant_deps) +api_router.include_router(step_categories.router, dependencies=_pro_deps) +api_router.include_router(steps.router, dependencies=_pro_deps) api_router.include_router(accounts.router, dependencies=_tenant_deps) api_router.include_router(shares.router, dependencies=_tenant_deps) api_router.include_router(tree_markdown.router, dependencies=_tenant_deps) api_router.include_router(ratings.router, dependencies=_tenant_deps) -api_router.include_router(analytics.router, dependencies=_tenant_deps) +api_router.include_router(analytics.router, dependencies=_pro_deps) api_router.include_router(target_lists.router, dependencies=_tenant_deps) api_router.include_router(maintenance_schedules.router, dependencies=_tenant_deps) api_router.include_router(feedback.router, dependencies=_tenant_deps) @@ -126,31 +147,31 @@ api_router.include_router(ai_builder.router, dependencies=_tenant_deps) api_router.include_router(ai_fix.router, dependencies=_tenant_deps) api_router.include_router(ai_chat.router, dependencies=_tenant_deps) api_router.include_router(copilot.router, dependencies=_tenant_deps) -api_router.include_router(assistant_chat.router, dependencies=_tenant_deps) +api_router.include_router(assistant_chat.router, dependencies=_pro_deps) api_router.include_router(tree_transfer.router, dependencies=_tenant_deps) api_router.include_router(ai_suggestions.router, dependencies=_tenant_deps) api_router.include_router(kb_accelerator.router, dependencies=_tenant_deps) -api_router.include_router(scripts.router, dependencies=_tenant_deps) -api_router.include_router(integrations.router, dependencies=_tenant_deps) +api_router.include_router(scripts.router, dependencies=_pro_deps) +api_router.include_router(integrations.router, dependencies=_pro_deps) api_router.include_router(onboarding.router, dependencies=_tenant_deps) api_router.include_router(branding.router, dependencies=_tenant_deps) api_router.include_router(supporting_data.router, dependencies=_tenant_deps) api_router.include_router(network_diagrams.router, dependencies=_tenant_deps) # session_handoffs queue router must come before ai_sessions to avoid conflict -api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps) -api_router.include_router(session_resolutions.router, dependencies=_tenant_deps) +api_router.include_router(session_handoffs.queue_router, dependencies=_pro_deps) +api_router.include_router(session_resolutions.router, dependencies=_pro_deps) # session_facts mounts under /ai-sessions/{id}/facts — register before ai_sessions # so the {session_id}/facts subpaths take precedence over any future generic catchalls. -api_router.include_router(session_facts.router, dependencies=_tenant_deps) -api_router.include_router(session_suggested_fixes.router, dependencies=_tenant_deps) +api_router.include_router(session_facts.router, dependencies=_pro_deps) +api_router.include_router(session_suggested_fixes.router, dependencies=_pro_deps) api_router.include_router(draft_templates.router, dependencies=_tenant_deps) -api_router.include_router(ai_sessions.router, dependencies=_tenant_deps) -api_router.include_router(flow_proposals.router, dependencies=_tenant_deps) -api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps) +api_router.include_router(ai_sessions.router, dependencies=_pro_deps) +api_router.include_router(flow_proposals.router, dependencies=_pro_deps) +api_router.include_router(flowpilot_analytics.router, dependencies=_pro_deps) api_router.include_router(notifications.router, dependencies=_tenant_deps) api_router.include_router(uploads.router, dependencies=_tenant_deps) -api_router.include_router(script_builder.router, dependencies=_tenant_deps) +api_router.include_router(script_builder.router, dependencies=_pro_deps) api_router.include_router(beta_feedback.router, dependencies=_tenant_deps) -api_router.include_router(session_branches.router, dependencies=_tenant_deps) -api_router.include_router(session_handoffs.router, dependencies=_tenant_deps) +api_router.include_router(session_branches.router, dependencies=_pro_deps) +api_router.include_router(session_handoffs.router, dependencies=_pro_deps) api_router.include_router(device_types.router, dependencies=_tenant_deps) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index afc2fcbc..f2c28593 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -94,11 +94,12 @@ class Settings(BaseSettings): STRIPE_SECRET_KEY: Optional[str] = None STRIPE_PUBLISHABLE_KEY: Optional[str] = None STRIPE_WEBHOOK_SECRET: Optional[str] = None + SELF_SERVE_ENABLED: bool = False @property def stripe_enabled(self) -> bool: """Check if Stripe is configured.""" - return self.STRIPE_SECRET_KEY is not None and self.STRIPE_WEBHOOK_SECRET is not None + return bool(self.STRIPE_SECRET_KEY) # AI Flow Builder ANTHROPIC_API_KEY: Optional[str] = None @@ -193,6 +194,13 @@ class Settings(BaseSettings): """Check if ConnectWise integration is configured.""" return self.CW_CLIENT_ID is not None + # OAuth providers (self-serve signup) + GOOGLE_CLIENT_ID: Optional[str] = None + GOOGLE_CLIENT_SECRET: Optional[str] = None + MS_CLIENT_ID: Optional[str] = None + MS_CLIENT_SECRET: Optional[str] = None + OAUTH_REDIRECT_BASE: str = "http://localhost:5173" + # Monitoring SENTRY_DSN: Optional[str] = None diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 52d90e19..e1c90f7c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -62,6 +62,10 @@ from .session_fact import SessionFact from .session_suggested_fix import SessionSuggestedFix from .draft_template import DraftTemplate from .account_settings import AccountSettings +from .oauth_identity import OAuthIdentity # noqa: F401 +from .plan_billing import PlanBilling # noqa: F401 +from .sales_lead import SalesLead # noqa: F401 +from .stripe_event import StripeEvent # noqa: F401 __all__ = [ "User", @@ -138,4 +142,8 @@ __all__ = [ "SessionSuggestedFix", "DraftTemplate", "AccountSettings", + "OAuthIdentity", + "PlanBilling", + "SalesLead", + "StripeEvent", ] diff --git a/backend/app/models/account.py b/backend/app/models/account.py index 78353111..aa2c5750 100644 --- a/backend/app/models/account.py +++ b/backend/app/models/account.py @@ -48,6 +48,8 @@ class Account(Base): branding_logo_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) branding_primary_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True) # hex like #06b6d4 branding_company_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + team_size_bucket: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) + primary_psa: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # SSO / SAML groundwork (Task 11) sso_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") diff --git a/backend/app/models/account_invite.py b/backend/app/models/account_invite.py index 43b3ed56..84009194 100644 --- a/backend/app/models/account_invite.py +++ b/backend/app/models/account_invite.py @@ -27,6 +27,8 @@ class AccountInvite(Base): expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + email_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) # Relationships account: Mapped["Account"] = relationship("Account") @@ -37,6 +39,10 @@ class AccountInvite(Base): def is_used(self) -> bool: return self.accepted_by_id is not None + @property + def is_revoked(self) -> bool: + return self.revoked_at is not None + @property def is_expired(self) -> bool: if self.expires_at is None: @@ -45,4 +51,4 @@ class AccountInvite(Base): @property def is_valid(self) -> bool: - return not self.is_used and not self.is_expired + return not self.is_used and not self.is_expired and not self.is_revoked diff --git a/backend/app/models/oauth_identity.py b/backend/app/models/oauth_identity.py new file mode 100644 index 00000000..07c4dbf4 --- /dev/null +++ b/backend/app/models/oauth_identity.py @@ -0,0 +1,36 @@ +import uuid +from datetime import datetime, timezone +from typing import TYPE_CHECKING +from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.user import User + + +class OAuthIdentity(Base): + __tablename__ = "oauth_identities" + __table_args__ = ( + UniqueConstraint("provider", "provider_subject", name="uq_oauth_identities_provider_subject"), + Index("ix_oauth_identities_user_id", "user_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + provider: Mapped[str] = mapped_column(String(20), nullable=False) + provider_subject: Mapped[str] = mapped_column(String(255), nullable=False) + provider_email_at_link: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + user: Mapped["User"] = relationship("User", backref="oauth_identities") diff --git a/backend/app/models/plan_billing.py b/backend/app/models/plan_billing.py new file mode 100644 index 00000000..64ca7575 --- /dev/null +++ b/backend/app/models/plan_billing.py @@ -0,0 +1,31 @@ +from datetime import datetime, timezone +from typing import Optional +from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Text +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class PlanBilling(Base): + __tablename__ = "plan_billing" + + plan: Mapped[str] = mapped_column( + String(50), ForeignKey("plan_limits.plan"), primary_key=True + ) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + monthly_price_cents: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + annual_price_cents: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + stripe_product_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + stripe_monthly_price_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + stripe_annual_price_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/models/sales_lead.py b/backend/app/models/sales_lead.py new file mode 100644 index 00000000..f9ffc071 --- /dev/null +++ b/backend/app/models/sales_lead.py @@ -0,0 +1,28 @@ +import uuid +from datetime import datetime, timezone +from typing import Optional +from sqlalchemy import String, DateTime, Text, Index +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + + +class SalesLead(Base): + __tablename__ = "sales_leads" + __table_args__ = (Index("ix_sales_leads_email", "email"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + company: Mapped[str] = mapped_column(String(255), nullable=False) + team_size: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) + message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + source: Mapped[str] = mapped_column(String(50), nullable=False) + posthog_distinct_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="new") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/models/stripe_event.py b/backend/app/models/stripe_event.py new file mode 100644 index 00000000..d362d814 --- /dev/null +++ b/backend/app/models/stripe_event.py @@ -0,0 +1,17 @@ +from datetime import datetime, timezone +from sqlalchemy import String, DateTime, Index +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import JSONB +from app.core.database import Base + + +class StripeEvent(Base): + __tablename__ = "stripe_events" + __table_args__ = (Index("ix_stripe_events_event_type", "event_type"),) + + id: Mapped[str] = mapped_column(String(255), primary_key=True) # Stripe event id + event_type: Mapped[str] = mapped_column(String(100), nullable=False) + processed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + payload_excerpt: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) diff --git a/backend/app/models/subscription.py b/backend/app/models/subscription.py index 54b2a440..11024582 100644 --- a/backend/app/models/subscription.py +++ b/backend/app/models/subscription.py @@ -32,8 +32,20 @@ class Subscription(Base): @property def is_active(self) -> bool: - return self.status in ("active", "trialing") + return self.status in ("active", "trialing", "complimentary") @property def is_paid(self) -> bool: - return self.plan in ("pro", "team") + # Excludes complimentary and trialing so MRR/paid-customer metrics aren't inflated. + return self.plan in ("pro", "team") and self.status not in ("complimentary", "trialing") + + @property + def has_pro_entitlement(self) -> bool: + """True if the account can access Pro features right now.""" + if self.plan in ("pro", "team"): + if self.status in ("active", "complimentary"): + return True + if self.status == "trialing" and self.current_period_end is not None: + from datetime import datetime, timezone + return self.current_period_end > datetime.now(timezone.utc) + return False diff --git a/backend/app/models/user.py b/backend/app/models/user.py index e1274183..ab25c8f0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime, timezone from typing import Optional, TYPE_CHECKING -from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text +from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text, Integer from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID from app.core.database import Base @@ -33,7 +33,7 @@ class User(Base): default=uuid.uuid4 ) email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) - password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) name: Mapped[str] = mapped_column(String(255), nullable=False) role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer") is_super_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) @@ -76,6 +76,8 @@ class User(Base): # Onboarding onboarding_dismissed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, server_default="false") + role_at_signup: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + onboarding_step_completed: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Branding (solo pros without a team) logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True) diff --git a/backend/app/schemas/account.py b/backend/app/schemas/account.py index 6909d3d7..3d1e0c28 100644 --- a/backend/app/schemas/account.py +++ b/backend/app/schemas/account.py @@ -42,3 +42,12 @@ class AccountInviteResponse(BaseModel): used_at: Optional[datetime] = None model_config = {"from_attributes": True} + + +class AccountInviteBulkCreate(BaseModel): + invites: list[AccountInviteCreate] + + +class AccountInviteBulkResponse(BaseModel): + created: list[AccountInviteResponse] + failed: list[dict] # entries shaped {"email": str, "error": str} diff --git a/backend/app/schemas/billing.py b/backend/app/schemas/billing.py new file mode 100644 index 00000000..ebe9ab9d --- /dev/null +++ b/backend/app/schemas/billing.py @@ -0,0 +1,40 @@ +from typing import Literal, Optional, Dict, Any +from datetime import datetime +from pydantic import BaseModel + + +class CheckoutSessionCreate(BaseModel): + plan: Literal["pro", "starter", "team", "enterprise"] + seats: int + billing_interval: Literal["monthly", "annual"] = "monthly" + + +class CheckoutSessionResponse(BaseModel): + url: str + + +class SubscriptionState(BaseModel): + status: str + plan: str + current_period_start: Optional[datetime] + current_period_end: Optional[datetime] + cancel_at_period_end: bool + seat_limit: Optional[int] + has_pro_entitlement: bool + is_paid: bool + + +class PlanBillingState(BaseModel): + display_name: str + description: Optional[str] = None + monthly_price_cents: Optional[int] = None + annual_price_cents: Optional[int] = None + + model_config = {"from_attributes": True} + + +class BillingStateResponse(BaseModel): + subscription: SubscriptionState + plan_billing: Optional[PlanBillingState] + plan_limits: Dict[str, Any] + enabled_features: Dict[str, bool] diff --git a/backend/app/schemas/oauth.py b/backend/app/schemas/oauth.py new file mode 100644 index 00000000..47ddf9ca --- /dev/null +++ b/backend/app/schemas/oauth.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class OAuthCallbackPayload(BaseModel): + code: str + state: str | None = None + + +class OAuthCallbackResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + is_new_user: bool diff --git a/backend/app/services/billing.py b/backend/app/services/billing.py new file mode 100644 index 00000000..a104a5b1 --- /dev/null +++ b/backend/app/services/billing.py @@ -0,0 +1,296 @@ +"""Single billing service module. Stripe is the only impl — no provider +abstraction. Account row is canonical local state; Stripe is canonical +remote state; the webhook handler bridges the two.""" +from datetime import datetime, timezone, timedelta + +import stripe +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.models.account import Account +from app.models.plan_billing import PlanBilling +from app.models.stripe_event import StripeEvent +from app.models.subscription import Subscription + + +TRIAL_DAYS = 14 + + +class BillingService: + @staticmethod + async def start_trial(db: AsyncSession, account_id) -> Subscription: + """Idempotent. Creates a trialing Subscription on Pro for the account if + one doesn't exist; otherwise returns the existing row.""" + result = await db.execute( + select(Subscription).where(Subscription.account_id == account_id) + ) + existing = result.scalar_one_or_none() + if existing is not None: + return existing + + sub = Subscription( + account_id=account_id, + plan="pro", + status="trialing", + current_period_start=datetime.now(timezone.utc), + current_period_end=datetime.now(timezone.utc) + timedelta(days=TRIAL_DAYS), + ) + db.add(sub) + await db.commit() + await db.refresh(sub) + return sub + + @staticmethod + async def create_checkout_session( + db: AsyncSession, + account: Account, + plan: str, + seats: int, + billing_interval: str, + success_url: str, + cancel_url: str, + ) -> str: + """Create a Stripe Checkout Session for subscription purchase. If the + account currently has a trialing subscription with time remaining, that + trial end is preserved on the new Stripe subscription so the user + isn't charged early.""" + if not settings.stripe_enabled: + raise RuntimeError("Stripe not configured") + stripe.api_key = settings.STRIPE_SECRET_KEY + + plan_billing = (await db.execute( + select(PlanBilling).where(PlanBilling.plan == plan) + )).scalar_one_or_none() + if plan_billing is None: + raise ValueError(f"Unknown plan: {plan}") + price_id = ( + plan_billing.stripe_monthly_price_id if billing_interval == "monthly" + else plan_billing.stripe_annual_price_id + ) + if price_id is None: + raise RuntimeError( + f"Plan '{plan}' has no Stripe price for {billing_interval}" + ) + + if account.stripe_customer_id is None: + customer = stripe.Customer.create( + email=None, + metadata={"account_id": str(account.id)}, + ) + account.stripe_customer_id = customer.id + await db.commit() + + sub = (await db.execute( + select(Subscription).where(Subscription.account_id == account.id) + )).scalar_one_or_none() + subscription_data = {} + if ( + sub + and sub.status == "trialing" + and sub.current_period_end + and sub.current_period_end > datetime.now(timezone.utc) + ): + subscription_data["trial_end"] = int(sub.current_period_end.timestamp()) + + session = stripe.checkout.Session.create( + customer=account.stripe_customer_id, + line_items=[{"price": price_id, "quantity": seats}], + mode="subscription", + subscription_data=subscription_data or None, + success_url=success_url, + cancel_url=cancel_url, + allow_promotion_codes=False, + ) + return session.url + + @staticmethod + async def get_billing_state(db: AsyncSession, account): + """Aggregate Subscription + PlanLimits + PlanBilling + resolved feature + flags for the account.""" + from app.models.plan_limits import PlanLimits + from app.models.plan_billing import PlanBilling + from app.models.feature_flag import ( + FeatureFlag, PlanFeatureDefault, AccountFeatureOverride, + ) + + sub = (await db.execute( + select(Subscription).where(Subscription.account_id == account.id) + )).scalar_one_or_none() + if sub is None: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="No subscription for account") + + pl = (await db.execute( + select(PlanLimits).where(PlanLimits.plan == sub.plan) + )).scalar_one_or_none() + pb = (await db.execute( + select(PlanBilling).where(PlanBilling.plan == sub.plan) + )).scalar_one_or_none() + + # Resolved feature flags: plan defaults overridden by account overrides + defaults = (await db.execute( + select(PlanFeatureDefault, FeatureFlag) + .join(FeatureFlag, PlanFeatureDefault.flag_id == FeatureFlag.id) + .where(PlanFeatureDefault.plan == sub.plan) + )).all() + resolved = {flag.flag_key: pfd.enabled for pfd, flag in defaults} + overrides = (await db.execute( + select(AccountFeatureOverride, FeatureFlag) + .join(FeatureFlag, AccountFeatureOverride.flag_id == FeatureFlag.id) + .where(AccountFeatureOverride.account_id == account.id) + )).all() + for ovr, flag in overrides: + resolved[flag.flag_key] = ovr.enabled + + return { + "subscription": { + "status": sub.status, + "plan": sub.plan, + "current_period_start": sub.current_period_start, + "current_period_end": sub.current_period_end, + "cancel_at_period_end": sub.cancel_at_period_end, + "seat_limit": sub.seat_limit, + "has_pro_entitlement": sub.has_pro_entitlement, + "is_paid": sub.is_paid, + }, + "plan_billing": pb, + "plan_limits": _plan_limits_to_dict(pl) if pl else {}, + "enabled_features": resolved, + } + + @staticmethod + async def apply_subscription_event( + db: AsyncSession, event_id: str, event_type: str, payload: dict + ) -> bool: + """Idempotent. Returns True if the event was applied; False if it had + already been processed (idempotent ack). The webhook handler returns 200 + either way.""" + try: + db.add(StripeEvent( + id=event_id, + event_type=event_type, + payload_excerpt=_excerpt(payload), + )) + await db.commit() + except IntegrityError: + await db.rollback() + return False + + if event_type == "checkout.session.completed": + await _handle_checkout_completed(db, payload) + elif event_type == "customer.subscription.updated": + await _handle_subscription_updated(db, payload) + elif event_type == "customer.subscription.deleted": + await _handle_subscription_deleted(db, payload) + elif event_type == "invoice.payment_failed": + await _handle_payment_failed(db, payload) + elif event_type == "invoice.payment_succeeded": + await _handle_payment_succeeded(db, payload) + return True + + +def _plan_limits_to_dict(pl) -> dict: + return {c.name: getattr(pl, c.name) for c in pl.__table__.columns} + + +def _excerpt(payload: dict) -> dict: + obj = payload.get("data", {}).get("object", {}) + return { + "object_id": obj.get("id"), + "customer": obj.get("customer"), + "subscription": obj.get("subscription"), + "status": obj.get("status"), + } + + +async def _handle_checkout_completed(db: AsyncSession, payload: dict): + obj = payload["data"]["object"] + customer_id = obj["customer"] + subscription_id = obj["subscription"] + + account = (await db.execute( + select(Account).where(Account.stripe_customer_id == customer_id) + )).scalar_one_or_none() + if account is None: + return + + sub = (await db.execute( + select(Subscription).where(Subscription.account_id == account.id) + )).scalar_one_or_none() + if sub is None: + return + + stripe.api_key = settings.STRIPE_SECRET_KEY + stripe_sub = stripe.Subscription.retrieve(subscription_id) + sub.stripe_subscription_id = subscription_id + sub.stripe_price_id = stripe_sub["items"]["data"][0]["price"]["id"] + sub.status = "active" + sub.current_period_start = datetime.fromtimestamp(stripe_sub["current_period_start"], tz=timezone.utc) + sub.current_period_end = datetime.fromtimestamp(stripe_sub["current_period_end"], tz=timezone.utc) + sub.seat_limit = stripe_sub["items"]["data"][0]["quantity"] + pb = (await db.execute( + select(PlanBilling).where( + (PlanBilling.stripe_monthly_price_id == sub.stripe_price_id) | + (PlanBilling.stripe_annual_price_id == sub.stripe_price_id) + ) + )).scalar_one_or_none() + if pb is not None: + sub.plan = pb.plan + await db.commit() + + +async def _handle_subscription_updated(db: AsyncSession, payload: dict): + obj = payload["data"]["object"] + sub = (await db.execute( + select(Subscription).where(Subscription.stripe_subscription_id == obj["id"]) + )).scalar_one_or_none() + if sub is None: + return + sub.status = obj["status"] + sub.current_period_start = datetime.fromtimestamp(obj["current_period_start"], tz=timezone.utc) + sub.current_period_end = datetime.fromtimestamp(obj["current_period_end"], tz=timezone.utc) + sub.cancel_at_period_end = obj.get("cancel_at_period_end", False) + sub.seat_limit = obj["items"]["data"][0]["quantity"] + await db.commit() + + +async def _handle_subscription_deleted(db: AsyncSession, payload: dict): + obj = payload["data"]["object"] + sub = (await db.execute( + select(Subscription).where(Subscription.stripe_subscription_id == obj["id"]) + )).scalar_one_or_none() + if sub is None: + return + sub.status = "canceled" + await db.commit() + + +async def _handle_payment_failed(db: AsyncSession, payload: dict): + obj = payload["data"]["object"] + subscription_id = obj.get("subscription") + if not subscription_id: + return + sub = (await db.execute( + select(Subscription).where(Subscription.stripe_subscription_id == subscription_id) + )).scalar_one_or_none() + if sub is None: + return + sub.status = "past_due" + await db.commit() + + +async def _handle_payment_succeeded(db: AsyncSession, payload: dict): + obj = payload["data"]["object"] + subscription_id = obj.get("subscription") + if not subscription_id: + return + sub = (await db.execute( + select(Subscription).where(Subscription.stripe_subscription_id == subscription_id) + )).scalar_one_or_none() + if sub is None: + return + if sub.status == "past_due": + sub.status = "active" + await db.commit() diff --git a/backend/app/services/oauth_providers.py b/backend/app/services/oauth_providers.py new file mode 100644 index 00000000..947743a5 --- /dev/null +++ b/backend/app/services/oauth_providers.py @@ -0,0 +1,71 @@ +"""OAuth provider helpers. Each provider exposes: +- exchange_code(code, redirect_uri) -> OAuthProfile +""" +from dataclasses import dataclass + +import httpx +from app.core.config import settings + + +@dataclass +class OAuthProfile: + provider_subject: str + email: str + name: str + + +async def google_exchange_code(code: str, redirect_uri: str) -> OAuthProfile: + async with httpx.AsyncClient(timeout=10) as cli: + token_response = await cli.post( + "https://oauth2.googleapis.com/token", + data={ + "code": code, + "client_id": settings.GOOGLE_CLIENT_ID, + "client_secret": settings.GOOGLE_CLIENT_SECRET, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + }, + ) + token_response.raise_for_status() + access_token = token_response.json()["access_token"] + + userinfo = await cli.get( + "https://openidconnect.googleapis.com/v1/userinfo", + headers={"Authorization": f"Bearer {access_token}"}, + ) + userinfo.raise_for_status() + data = userinfo.json() + return OAuthProfile( + provider_subject=data["sub"], + email=data["email"], + name=data.get("name") or data["email"].split("@")[0], + ) + + +async def microsoft_exchange_code(code: str, redirect_uri: str) -> OAuthProfile: + async with httpx.AsyncClient(timeout=10) as cli: + token_response = await cli.post( + "https://login.microsoftonline.com/common/oauth2/v2.0/token", + data={ + "code": code, + "client_id": settings.MS_CLIENT_ID, + "client_secret": settings.MS_CLIENT_SECRET, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + "scope": "openid email profile", + }, + ) + token_response.raise_for_status() + access_token = token_response.json()["access_token"] + + userinfo = await cli.get( + "https://graph.microsoft.com/v1.0/me", + headers={"Authorization": f"Bearer {access_token}"}, + ) + userinfo.raise_for_status() + data = userinfo.json() + return OAuthProfile( + provider_subject=data["id"], + email=data.get("mail") or data["userPrincipalName"], + name=data.get("displayName") or data["userPrincipalName"].split("@")[0], + ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 606609df..5f1d21c9 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -248,13 +248,23 @@ async def client(test_db: AsyncSession): @pytest.fixture -async def test_user(client): +async def test_user(client, test_db): """ Create a test user and return their credentials. + Also seeds a default active Pro Subscription so Pro-guarded routes work + in tests. Phase 1 Task 11 added require_active_subscription; without + this seed every existing test that hits a Pro router would 402. The + register endpoint creates a default `free`/`active` Subscription, so + we delete-then-insert to avoid the unique account_id constraint. + Returns: dict with email, password, and user_data """ + import uuid + from sqlalchemy import delete + from app.models.subscription import Subscription + user_data = { "email": "test@example.com", "password": "TestPassword123!", @@ -264,6 +274,13 @@ async def test_user(client): response = await client.post("/api/v1/auth/register", json=user_data) assert response.status_code == 200 or response.status_code == 201 + account_id = uuid.UUID(response.json()["account_id"]) + await test_db.execute( + delete(Subscription).where(Subscription.account_id == account_id) + ) + test_db.add(Subscription(account_id=account_id, plan="pro", status="active")) + await test_db.commit() + return { "email": user_data["email"], "password": user_data["password"], @@ -346,11 +363,14 @@ async def test_admin(client, test_db): Create a test super-admin user. Registers as engineer (the only role available at registration), - then promotes to super_admin directly via the DB session. + then promotes to super_admin directly via the DB session. Also + seeds a default active Pro Subscription (see test_user docstring). """ + import uuid from uuid import UUID as PyUUID - from sqlalchemy import select + from sqlalchemy import select, delete from app.models.user import User + from app.models.subscription import Subscription admin_data = { "email": "admin@example.com", @@ -365,6 +385,12 @@ async def test_admin(client, test_db): result = await test_db.execute(select(User).where(User.id == user_id)) user = result.scalar_one() user.is_super_admin = True + + account_id = uuid.UUID(response.json()["account_id"]) + await test_db.execute( + delete(Subscription).where(Subscription.account_id == account_id) + ) + test_db.add(Subscription(account_id=account_id, plan="pro", status="active")) await test_db.commit() return { diff --git a/backend/tests/test_account_invite_extensions.py b/backend/tests/test_account_invite_extensions.py new file mode 100644 index 00000000..698ec84b --- /dev/null +++ b/backend/tests/test_account_invite_extensions.py @@ -0,0 +1,180 @@ +import pytest +from unittest.mock import AsyncMock, patch +from sqlalchemy import select +from app.models.account_invite import AccountInvite + + +@pytest.mark.asyncio +async def test_create_invite_sends_email_and_stamps_email_sent_at( + client, test_db, test_user, auth_headers +): + """Regression: today's create_invite does NOT send email. After this task, it MUST.""" + with patch( + "app.core.email.EmailService.send_account_invite_email", + new_callable=AsyncMock, return_value=True, + ) as mock_send: + response = await client.post( + "/api/v1/accounts/me/invites", + json={"email": "teammate@example.com", "role": "engineer"}, + headers=auth_headers, + ) + assert response.status_code == 201, response.json() + mock_send.assert_called_once() + kwargs = mock_send.call_args.kwargs + assert kwargs["to_email"] == "teammate@example.com" + assert kwargs["role"] == "engineer" + assert kwargs["code"] + + invite = (await test_db.execute( + select(AccountInvite).where(AccountInvite.email == "teammate@example.com") + )).scalar_one() + assert invite.email_sent_at is not None + + +@pytest.mark.asyncio +async def test_create_invite_email_failure_still_creates_row( + client, test_db, test_user, auth_headers +): + """When EmailService returns False, the invite row is still created but + email_sent_at remains NULL.""" + with patch( + "app.core.email.EmailService.send_account_invite_email", + new_callable=AsyncMock, return_value=False, + ): + response = await client.post( + "/api/v1/accounts/me/invites", + json={"email": "fail-mail@example.com", "role": "engineer"}, + headers=auth_headers, + ) + assert response.status_code == 201 + + invite = (await test_db.execute( + select(AccountInvite).where(AccountInvite.email == "fail-mail@example.com") + )).scalar_one() + assert invite.email_sent_at is None + + +@pytest.mark.asyncio +async def test_bulk_invite_creates_n_rows_and_sends_n_emails( + client, test_db, test_user, auth_headers +): + with patch( + "app.core.email.EmailService.send_account_invite_email", + new_callable=AsyncMock, return_value=True, + ) as mock_send: + response = await client.post( + "/api/v1/accounts/me/invites/bulk", + json={"invites": [ + {"email": "a@example.com", "role": "engineer"}, + {"email": "b@example.com", "role": "engineer"}, + {"email": "c@example.com", "role": "viewer"}, + ]}, + headers=auth_headers, + ) + assert response.status_code == 201, response.json() + body = response.json() + assert len(body["created"]) == 3 + assert body["failed"] == [] + assert mock_send.call_count == 3 + + +@pytest.mark.asyncio +async def test_revoke_invite_sets_revoked_at(client, test_db, test_user, auth_headers): + import uuid + from datetime import datetime, timezone, timedelta + from app.models.account_invite import AccountInvite + + invited_by_id = uuid.UUID(test_user["user_data"]["id"]) + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + + invite = AccountInvite( + account_id=account_id, + invited_by_id=invited_by_id, + email="revoked@example.com", + code="REVOKEME01", + role="engineer", + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + ) + test_db.add(invite) + await test_db.commit() + invite_id = invite.id + + response = await client.delete( + f"/api/v1/accounts/me/invites/{invite_id}", + headers=auth_headers, + ) + assert response.status_code == 204 + + await test_db.refresh(invite) + assert invite.revoked_at is not None + assert invite.is_valid is False + + +@pytest.mark.asyncio +async def test_revoke_invite_idempotent(client, test_db, test_user, auth_headers): + import uuid + from datetime import datetime, timezone, timedelta + from app.models.account_invite import AccountInvite + + invited_by_id = uuid.UUID(test_user["user_data"]["id"]) + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + + invite = AccountInvite( + account_id=account_id, + invited_by_id=invited_by_id, + email="revoked2@example.com", + code="REVOKEME02", + role="engineer", + revoked_at=datetime.now(timezone.utc), + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + ) + test_db.add(invite) + await test_db.commit() + invite_id = invite.id + + response = await client.delete( + f"/api/v1/accounts/me/invites/{invite_id}", + headers=auth_headers, + ) + assert response.status_code == 204 + + +@pytest.mark.asyncio +async def test_revoke_invite_404_when_not_found(client, test_user, auth_headers): + import uuid + response = await client.delete( + f"/api/v1/accounts/me/invites/{uuid.uuid4()}", + headers=auth_headers, + ) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_revoke_used_invite_returns_400( + client, test_db, test_user, auth_headers +): + import uuid + from datetime import datetime, timezone, timedelta + from app.models.account_invite import AccountInvite + + invited_by_id = uuid.UUID(test_user["user_data"]["id"]) + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + + invite = AccountInvite( + account_id=account_id, + invited_by_id=invited_by_id, + email="used@example.com", + code="USEDCODE01", + role="engineer", + accepted_by_id=invited_by_id, # mark as used + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + ) + test_db.add(invite) + await test_db.commit() + invite_id = invite.id + + response = await client.delete( + f"/api/v1/accounts/me/invites/{invite_id}", + headers=auth_headers, + ) + assert response.status_code == 400 diff --git a/backend/tests/test_account_invite_model.py b/backend/tests/test_account_invite_model.py new file mode 100644 index 00000000..4c62266b --- /dev/null +++ b/backend/tests/test_account_invite_model.py @@ -0,0 +1,27 @@ +import pytest +from datetime import datetime, timezone, timedelta +from app.models.account_invite import AccountInvite + + +def make_invite(**kwargs): + return AccountInvite( + account_id=kwargs.get("account_id", "00000000-0000-0000-0000-000000000001"), + invited_by_id=kwargs.get("invited_by_id", "00000000-0000-0000-0000-000000000002"), + email=kwargs.get("email", "x@y.com"), + code=kwargs.get("code", "ABCD1234"), + role=kwargs.get("role", "engineer"), + accepted_by_id=kwargs.get("accepted_by_id"), + expires_at=kwargs.get("expires_at"), + revoked_at=kwargs.get("revoked_at"), + ) + + +def test_invite_revoked_is_invalid(): + invite = make_invite(revoked_at=datetime.now(timezone.utc)) + assert invite.is_revoked is True + assert invite.is_valid is False + + +def test_invite_unrevoked_unexpired_unused_is_valid(): + invite = make_invite(expires_at=datetime.now(timezone.utc) + timedelta(days=7)) + assert invite.is_valid is True diff --git a/backend/tests/test_account_management.py b/backend/tests/test_account_management.py index a8fed198..60031fba 100644 --- a/backend/tests/test_account_management.py +++ b/backend/tests/test_account_management.py @@ -21,17 +21,21 @@ class TestAccountEndpoints: @pytest.mark.asyncio async def test_get_my_subscription(self, client: AsyncClient, auth_headers: dict): - """Test getting current user's subscription details.""" + """Test getting current user's subscription details. + + The test_user fixture seeds a Pro/active Subscription so + Pro-guarded routers work; reflect that in the expected plan. + """ response = await client.get("/api/v1/accounts/me/subscription", headers=auth_headers) assert response.status_code == 200 data = response.json() assert "subscription" in data assert "limits" in data assert "usage" in data - assert data["subscription"]["plan"] == "free" + assert data["subscription"]["plan"] == "pro" assert data["subscription"]["status"] == "active" - assert data["limits"]["max_trees"] == 3 - assert data["limits"]["max_sessions_per_month"] == 20 + assert data["limits"]["max_trees"] == 25 + assert data["limits"]["max_sessions_per_month"] == 200 @pytest.mark.asyncio async def test_get_my_members(self, client: AsyncClient, auth_headers: dict): diff --git a/backend/tests/test_billing_checkout.py b/backend/tests/test_billing_checkout.py new file mode 100644 index 00000000..48e12f75 --- /dev/null +++ b/backend/tests/test_billing_checkout.py @@ -0,0 +1,56 @@ +import pytest +from unittest.mock import patch, MagicMock +from app.models.plan_billing import PlanBilling + + +@pytest.mark.asyncio +async def test_checkout_session_creates_stripe_session( + client, test_db, test_user, auth_headers, monkeypatch +): + """End-to-end: post body → Stripe SDK called → URL returned. Stripe SDK + mocked; Customer + Session calls patched.""" + from app.core.config import settings + monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy") + + test_db.add(PlanBilling( + plan="pro", + display_name="Pro", + stripe_product_id="prod_test", + stripe_monthly_price_id="price_test_monthly", + )) + await test_db.commit() + + fake_customer = MagicMock() + fake_customer.id = "cus_test_123" + fake_session = MagicMock() + fake_session.url = "https://checkout.stripe.com/test" + + with patch("stripe.Customer.create", return_value=fake_customer) as cust_mock, \ + patch("stripe.checkout.Session.create", return_value=fake_session) as sess_mock: + response = await client.post( + "/api/v1/billing/checkout-session", + json={"plan": "pro", "seats": 3, "billing_interval": "monthly"}, + headers=auth_headers, + ) + + assert response.status_code == 200, response.json() + assert response.json()["url"] == "https://checkout.stripe.com/test" + cust_mock.assert_called_once() + sess_mock.assert_called_once() + + +@pytest.mark.asyncio +async def test_checkout_session_unknown_plan_returns_500( + client, test_db, test_user, auth_headers, monkeypatch +): + """No PlanBilling row → ValueError surfaces as 500 (the endpoint doesn't + catch business errors).""" + from app.core.config import settings + monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy") + + response = await client.post( + "/api/v1/billing/checkout-session", + json={"plan": "pro", "seats": 1, "billing_interval": "monthly"}, + headers=auth_headers, + ) + assert response.status_code == 500 diff --git a/backend/tests/test_billing_service.py b/backend/tests/test_billing_service.py new file mode 100644 index 00000000..7ba62708 --- /dev/null +++ b/backend/tests/test_billing_service.py @@ -0,0 +1,80 @@ +import uuid +import pytest +from datetime import datetime, timezone +from sqlalchemy import select, delete +from app.models.subscription import Subscription +from app.services.billing import BillingService + + +@pytest.mark.asyncio +async def test_start_trial_creates_trialing_pro_subscription(test_db): + """Direct service test — bypasses register, creates account inline.""" + from app.models.account import Account + account = Account(name="DirectTest", display_code="DIRECT01") + test_db.add(account) + await test_db.flush() + + sub = await BillingService.start_trial(test_db, account.id) + assert sub.plan == "pro" + assert sub.status == "trialing" + assert sub.current_period_end is not None + assert sub.current_period_end > datetime.now(timezone.utc) + + +@pytest.mark.asyncio +async def test_start_trial_is_idempotent(test_db): + from app.models.account import Account + account = Account(name="Idempo", display_code="IDEMPO01") + test_db.add(account) + await test_db.flush() + + sub1 = await BillingService.start_trial(test_db, account.id) + sub2 = await BillingService.start_trial(test_db, account.id) + assert sub1.id == sub2.id + + rows = (await test_db.execute( + select(Subscription).where(Subscription.account_id == account.id) + )).scalars().all() + assert len(rows) == 1 + + +@pytest.mark.asyncio +async def test_register_creates_trial_subscription(client, test_db): + """Registering a brand-new shop (no invite code) yields a Pro/trialing sub.""" + response = await client.post("/api/v1/auth/register", json={ + "email": "newshop@example.com", + "password": "Verystrong1Pwd", + "name": "New Shop", + }) + assert response.status_code in (200, 201), response.json() + + body = response.json() + account_id = uuid.UUID(body["account_id"]) + + sub = (await test_db.execute( + select(Subscription).where(Subscription.account_id == account_id) + )).scalar_one() + assert sub.plan == "pro" + assert sub.status == "trialing" + assert sub.current_period_end is not None + + +@pytest.mark.asyncio +async def test_apply_subscription_event_is_idempotent(test_db): + payload = { + "data": {"object": { + "id": "evt_test_1", + "customer": "cus_xxx", + "subscription": "sub_xxx", + "status": "active", + }} + } + + applied_first = await BillingService.apply_subscription_event( + test_db, "evt_test_1", "customer.subscription.updated", payload + ) + applied_second = await BillingService.apply_subscription_event( + test_db, "evt_test_1", "customer.subscription.updated", payload + ) + assert applied_first is True + assert applied_second is False # already-processed → ack without re-applying diff --git a/backend/tests/test_billing_state_endpoint.py b/backend/tests/test_billing_state_endpoint.py new file mode 100644 index 00000000..2f27b73c --- /dev/null +++ b/backend/tests/test_billing_state_endpoint.py @@ -0,0 +1,64 @@ +import uuid +import pytest +from sqlalchemy import select +from app.models.subscription import Subscription +from app.models.feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride + + +@pytest.mark.asyncio +async def test_billing_state_returns_subscription_plan_features( + client, test_db, test_user, auth_headers +): + """Subscription is already seeded by test_user fixture (pro/active). + Add a feature flag default for `pro` and verify it shows up in the response.""" + flag = FeatureFlag(flag_key="psa_integration", display_name="PSA Integration") + test_db.add(flag) + await test_db.flush() + test_db.add(PlanFeatureDefault(plan="pro", flag_id=flag.id, enabled=True)) + await test_db.commit() + + response = await client.get("/api/v1/billing/state", headers=auth_headers) + assert response.status_code == 200, response.json() + body = response.json() + assert body["subscription"]["status"] == "active" + assert body["subscription"]["plan"] == "pro" + assert body["subscription"]["has_pro_entitlement"] is True + assert body["subscription"]["is_paid"] is True + assert body["enabled_features"]["psa_integration"] is True + # plan_limits should be a dict with the seeded pro limits from conftest + assert body["plan_limits"]["plan"] == "pro" + assert body["plan_limits"]["max_trees"] == 25 + + +@pytest.mark.asyncio +async def test_billing_state_account_override_beats_plan_default( + client, test_db, test_user, auth_headers +): + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + + flag = FeatureFlag(flag_key="escalation_mode", display_name="Escalation Mode") + test_db.add(flag) + await test_db.flush() + test_db.add(PlanFeatureDefault(plan="pro", flag_id=flag.id, enabled=False)) + test_db.add(AccountFeatureOverride( + account_id=account_id, flag_id=flag.id, enabled=True, + )) + await test_db.commit() + + response = await client.get("/api/v1/billing/state", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["enabled_features"]["escalation_mode"] is True + + +@pytest.mark.asyncio +async def test_billing_state_404_when_no_subscription( + client, test_db, test_user, auth_headers +): + """Wipe the seeded subscription and verify the endpoint surfaces 404.""" + from sqlalchemy import delete + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id)) + await test_db.commit() + + response = await client.get("/api/v1/billing/state", headers=auth_headers) + assert response.status_code == 404 diff --git a/backend/tests/test_email_verification_autosend.py b/backend/tests/test_email_verification_autosend.py new file mode 100644 index 00000000..bde5c46e --- /dev/null +++ b/backend/tests/test_email_verification_autosend.py @@ -0,0 +1,98 @@ +import pytest +from datetime import datetime, timezone, timedelta +from unittest.mock import AsyncMock, patch +from sqlalchemy import select + + +@pytest.mark.asyncio +async def test_register_auto_sends_verification_email(client, test_db): + """Fresh registration triggers send_email_verification_email.""" + with patch( + "app.core.email.EmailService.send_email_verification_email", + new_callable=AsyncMock, + ) as mock_send: + response = await client.post("/api/v1/auth/register", json={ + "email": "newshop@example.com", + "password": "Verystrong1Pwd", + "name": "New Shop", + }) + assert response.status_code in (200, 201), response.json() + mock_send.assert_called_once() + kwargs = mock_send.call_args.kwargs + assert kwargs["to_email"] == "newshop@example.com" + assert "/verify-email?token=" in kwargs["verification_url"] + + +@pytest.mark.asyncio +async def test_register_with_account_invite_code_email_mismatch_rejected( + client, test_db, test_user +): + """Invite code is for invited@example.com but user registers with a + different email -> 400 invite_email_mismatch.""" + from app.models.account_invite import AccountInvite + import uuid + + invited_by_id = uuid.UUID(test_user["user_data"]["id"]) + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + + invite = AccountInvite( + account_id=account_id, + invited_by_id=invited_by_id, + email="invited@example.com", + code="INVITECODE99", + role="engineer", + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + ) + test_db.add(invite) + await test_db.commit() + + response = await client.post("/api/v1/auth/register", json={ + "email": "wrong-email@example.com", + "password": "Verystrong1Pwd", + "name": "Wrong Email", + "account_invite_code": "INVITECODE99", + }) + assert response.status_code == 400, response.json() + assert response.json()["detail"]["error"] == "invite_email_mismatch" + + +@pytest.mark.asyncio +async def test_register_with_account_invite_code_email_match_accepted( + client, test_db, test_user +): + """Invite code is for invited@example.com - registering with that email + succeeds and joins the existing account.""" + from app.models.account_invite import AccountInvite + from app.models.user import User + import uuid + + invited_by_id = uuid.UUID(test_user["user_data"]["id"]) + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + + invite = AccountInvite( + account_id=account_id, + invited_by_id=invited_by_id, + email="invited@example.com", + code="INVITECODE100", + role="engineer", + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + ) + test_db.add(invite) + await test_db.commit() + + with patch( + "app.core.email.EmailService.send_email_verification_email", + new_callable=AsyncMock, + ): + response = await client.post("/api/v1/auth/register", json={ + "email": "invited@example.com", + "password": "Verystrong1Pwd", + "name": "Invited", + "account_invite_code": "INVITECODE100", + }) + assert response.status_code in (200, 201), response.json() + + new_user = (await test_db.execute( + select(User).where(User.email == "invited@example.com") + )).scalar_one() + assert new_user.account_id == account_id # joined existing account diff --git a/backend/tests/test_email_verification_guard.py b/backend/tests/test_email_verification_guard.py new file mode 100644 index 00000000..70f15da1 --- /dev/null +++ b/backend/tests/test_email_verification_guard.py @@ -0,0 +1,87 @@ +import uuid +import pytest +from datetime import datetime, timezone, timedelta +from sqlalchemy import select +from app.models.user import User + + +async def _set_user_email_state(test_db, user_id, *, verified_at=None, created_at=None): + user = (await test_db.execute(select(User).where(User.id == user_id))).scalar_one() + user.email_verified_at = verified_at + if created_at is not None: + user.created_at = created_at + await test_db.commit() + + +@pytest.mark.asyncio +async def test_verified_user_passes(client, test_db, test_user, auth_headers): + user_id = uuid.UUID(test_user["user_data"]["id"]) + await _set_user_email_state(test_db, user_id, verified_at=datetime.now(timezone.utc)) + response = await client.get("/api/v1/trees", headers=auth_headers) + assert response.status_code != 403 + + +@pytest.mark.asyncio +async def test_unverified_in_grace_passes(client, test_db, test_user, auth_headers): + user_id = uuid.UUID(test_user["user_data"]["id"]) + await _set_user_email_state( + test_db, user_id, + verified_at=None, + created_at=datetime.now(timezone.utc) - timedelta(days=2), + ) + response = await client.get("/api/v1/trees", headers=auth_headers) + assert response.status_code != 403 + + +@pytest.mark.asyncio +async def test_unverified_past_grace_blocks(client, test_db, test_user, auth_headers): + user_id = uuid.UUID(test_user["user_data"]["id"]) + await _set_user_email_state( + test_db, user_id, + verified_at=None, + created_at=datetime.now(timezone.utc) - timedelta(days=10), + ) + response = await client.get("/api/v1/trees", headers=auth_headers) + assert response.status_code == 403 + body = response.json() + assert body["detail"]["error"] == "email_not_verified" + + +@pytest.mark.asyncio +async def test_unverified_past_grace_allowlisted_still_passes(client, test_db, test_user, auth_headers): + user_id = uuid.UUID(test_user["user_data"]["id"]) + await _set_user_email_state( + test_db, user_id, + verified_at=None, + created_at=datetime.now(timezone.utc) - timedelta(days=10), + ) + response = await client.get("/api/v1/auth/me", headers=auth_headers) + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_combined_guards_unverified_expired_trial(client, test_db, test_user, auth_headers): + """A user who is BOTH past grace AND on an expired trial should get blocked + by one of the two guards. Either error is acceptable; we just verify a + refusal.""" + from app.models.subscription import Subscription + from sqlalchemy import delete + + user_id = uuid.UUID(test_user["user_data"]["id"]) + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + await _set_user_email_state( + test_db, user_id, + verified_at=None, + created_at=datetime.now(timezone.utc) - timedelta(days=10), + ) + + # Replace the seeded active sub with an expired trial + await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id)) + test_db.add(Subscription( + account_id=account_id, plan="pro", status="trialing", + current_period_end=datetime.now(timezone.utc) - timedelta(hours=1), + )) + await test_db.commit() + + response = await client.get("/api/v1/trees", headers=auth_headers) + assert response.status_code in (402, 403) diff --git a/backend/tests/test_get_current_active_user_no_mutation.py b/backend/tests/test_get_current_active_user_no_mutation.py new file mode 100644 index 00000000..c2911ecb --- /dev/null +++ b/backend/tests/test_get_current_active_user_no_mutation.py @@ -0,0 +1,45 @@ +import uuid +import pytest +from datetime import datetime, timezone, timedelta +from sqlalchemy import select +from app.models.subscription import Subscription + + +@pytest.mark.asyncio +async def test_expired_trial_is_not_mutated_by_get_current_active_user( + test_db, client, test_user, auth_headers +): + """The previous deps.py:109 logic mutated trialing→active+free on expiry. + That's gone. An expired-trial Subscription should retain status='trialing' + and current_period_end after any authenticated request.""" + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + + # If a Subscription already exists for this account (e.g. created by + # the register handler), update it; otherwise insert a new one. + existing = await test_db.execute( + select(Subscription).where(Subscription.account_id == account_id) + ) + sub = existing.scalar_one_or_none() + expired_end = datetime.now(timezone.utc) - timedelta(hours=1) + if sub is None: + sub = Subscription( + account_id=account_id, + plan="pro", + status="trialing", + current_period_end=expired_end, + ) + test_db.add(sub) + else: + sub.plan = "pro" + sub.status = "trialing" + sub.current_period_end = expired_end + await test_db.commit() + + # Call any authenticated endpoint that goes through get_current_active_user. + response = await client.get("/api/v1/auth/me", headers=auth_headers) + assert response.status_code == 200 + + await test_db.refresh(sub) + assert sub.status == "trialing" + assert sub.plan == "pro" + assert sub.current_period_end is not None diff --git a/backend/tests/test_kb_accelerator.py b/backend/tests/test_kb_accelerator.py index 54bb6fea..8978e5ab 100644 --- a/backend/tests/test_kb_accelerator.py +++ b/backend/tests/test_kb_accelerator.py @@ -13,6 +13,14 @@ pytestmark = pytest.mark.asyncio @pytest.fixture async def kb_setup(client, auth_headers, test_db): """Seed KB plan limits and return helpers.""" + # KB tests were authored against a free-plan user. Phase 1 conftest seeds + # the test_user with a pro/active Subscription; downgrade to free here so + # quota numbers match the original test intent. + from app.models.subscription import Subscription + sub = (await test_db.execute(__import__("sqlalchemy").select(Subscription))).scalar_one() + sub.plan = "free" + await test_db.commit() + # Update plan_limits with KB columns for 'free' plan await test_db.execute( __import__("sqlalchemy").text(""" diff --git a/backend/tests/test_oauth_callbacks.py b/backend/tests/test_oauth_callbacks.py new file mode 100644 index 00000000..f31e1688 --- /dev/null +++ b/backend/tests/test_oauth_callbacks.py @@ -0,0 +1,120 @@ +import uuid +import pytest +from unittest.mock import patch +from sqlalchemy import select +from app.models.user import User +from app.models.oauth_identity import OAuthIdentity +from app.models.subscription import Subscription +from app.services.oauth_providers import OAuthProfile + + +@pytest.mark.asyncio +async def test_google_callback_creates_user_account_subscription( + client, test_db, monkeypatch +): + """Brand-new user via Google OAuth -> User + Account + Subscription + OAuthIdentity.""" + from app.core.config import settings + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy") + monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy") + + profile = OAuthProfile( + provider_subject="google_subject_123", + email="newuser@example.com", + name="New User", + ) + with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile): + response = await client.post( + "/api/v1/auth/google/callback", json={"code": "auth_code_xyz"} + ) + assert response.status_code == 200, response.json() + body = response.json() + assert body["is_new_user"] is True + assert body["access_token"] + + user = (await test_db.execute( + select(User).where(User.email == "newuser@example.com") + )).scalar_one() + assert user.password_hash is None + assert user.email_verified_at is not None + + identity = (await test_db.execute( + select(OAuthIdentity).where(OAuthIdentity.user_id == user.id) + )).scalar_one() + assert identity.provider == "google" + assert identity.provider_subject == "google_subject_123" + + sub = (await test_db.execute( + select(Subscription).where(Subscription.account_id == user.account_id) + )).scalar_one() + assert sub.status == "trialing" + assert sub.plan == "pro" + + +@pytest.mark.asyncio +async def test_google_callback_existing_user_is_idempotent( + client, test_db, test_user, monkeypatch +): + """When test_user's email is already registered, OAuth links + returns the + same user. Two calls with same provider_subject must not duplicate + OAuthIdentity rows.""" + from app.core.config import settings + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy") + monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy") + + user_id = uuid.UUID(test_user["user_data"]["id"]) + email = test_user["email"] + name = test_user["user_data"]["name"] + + profile = OAuthProfile( + provider_subject="google_subject_456", + email=email, + name=name, + ) + with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile): + r1 = await client.post("/api/v1/auth/google/callback", json={"code": "x"}) + r2 = await client.post("/api/v1/auth/google/callback", json={"code": "x"}) + assert r1.status_code == 200 + assert r2.status_code == 200 + assert r1.json()["is_new_user"] is False + assert r2.json()["is_new_user"] is False + + identities = (await test_db.execute( + select(OAuthIdentity).where(OAuthIdentity.user_id == user_id) + )).scalars().all() + assert len(identities) == 1 + + +@pytest.mark.asyncio +async def test_google_callback_503_when_unconfigured(client, monkeypatch): + from app.core.config import settings + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None) + + response = await client.post( + "/api/v1/auth/google/callback", json={"code": "x"} + ) + assert response.status_code == 503 + + +@pytest.mark.asyncio +async def test_microsoft_callback_creates_user(client, test_db, monkeypatch): + from app.core.config import settings + monkeypatch.setattr(settings, "MS_CLIENT_ID", "client_dummy") + monkeypatch.setattr(settings, "MS_CLIENT_SECRET", "secret_dummy") + + profile = OAuthProfile( + provider_subject="ms_subject_789", + email="msuser@example.com", + name="MS User", + ) + with patch("app.api.endpoints.oauth.microsoft_exchange_code", return_value=profile): + response = await client.post( + "/api/v1/auth/microsoft/callback", json={"code": "auth_code"} + ) + assert response.status_code == 200, response.json() + user = (await test_db.execute( + select(User).where(User.email == "msuser@example.com") + )).scalar_one() + identity = (await test_db.execute( + select(OAuthIdentity).where(OAuthIdentity.user_id == user.id) + )).scalar_one() + assert identity.provider == "microsoft" diff --git a/backend/tests/test_oauth_identity_model.py b/backend/tests/test_oauth_identity_model.py new file mode 100644 index 00000000..cccaf7ec --- /dev/null +++ b/backend/tests/test_oauth_identity_model.py @@ -0,0 +1,39 @@ +import uuid + +import pytest +from sqlalchemy import select + +from app.models.oauth_identity import OAuthIdentity + + +@pytest.mark.asyncio +async def test_oauth_identity_unique_provider_subject(test_db, test_user): + """Two rows with same provider+subject should violate uniqueness.""" + user_id = uuid.UUID(test_user["user_data"]["id"]) + + row1 = OAuthIdentity( + user_id=user_id, + provider="google", + provider_subject="abc-123", + provider_email_at_link="alex@acmemsp.com", + ) + test_db.add(row1) + await test_db.commit() + + row2 = OAuthIdentity( + user_id=user_id, + provider="google", + provider_subject="abc-123", + provider_email_at_link="alex@acmemsp.com", + ) + test_db.add(row2) + with pytest.raises(Exception): # IntegrityError + await test_db.commit() + await test_db.rollback() + + rows = ( + await test_db.execute( + select(OAuthIdentity).where(OAuthIdentity.user_id == user_id) + ) + ).scalars().all() + assert len(rows) == 1 diff --git a/backend/tests/test_oauth_only_user_paths.py b/backend/tests/test_oauth_only_user_paths.py new file mode 100644 index 00000000..f030024c --- /dev/null +++ b/backend/tests/test_oauth_only_user_paths.py @@ -0,0 +1,83 @@ +import uuid +import pytest +from sqlalchemy import select +from app.models.user import User +from app.models.account import Account +from app.models.oauth_identity import OAuthIdentity + + +async def _make_oauth_only_user(test_db, email, *, with_identity=True): + """Create an OAuth-only user (password_hash=None) directly in the test DB.""" + import secrets + account = Account( + name=f"{email}-acct", + display_code=secrets.token_hex(4).upper(), + ) + test_db.add(account) + await test_db.flush() + user = User( + email=email, + name="OAuth User", + password_hash=None, + account_id=account.id, + account_role="owner", + ) + test_db.add(user) + await test_db.flush() + if with_identity: + test_db.add(OAuthIdentity( + user_id=user.id, provider="google", + provider_subject=f"google_{email}", + provider_email_at_link=email, + )) + await test_db.commit() + return user + + +@pytest.mark.asyncio +async def test_login_form_rejects_oauth_only_user_with_helpful_error(client, test_db): + await _make_oauth_only_user(test_db, "oauth-only@example.com") + response = await client.post( + "/api/v1/auth/login", + data={"username": "oauth-only@example.com", "password": "wontwork"}, + ) + assert response.status_code == 400 + body = response.json() + assert body["detail"]["error"] == "use_oauth_provider" + assert "google" in body["detail"]["providers"] + + +@pytest.mark.asyncio +async def test_login_json_rejects_oauth_only_user(client, test_db): + await _make_oauth_only_user(test_db, "oauth-only2@example.com") + response = await client.post( + "/api/v1/auth/login/json", + json={"email": "oauth-only2@example.com", "password": "wontwork"}, + ) + assert response.status_code == 400 + assert response.json()["detail"]["error"] == "use_oauth_provider" + + +@pytest.mark.asyncio +async def test_password_forgot_silent_for_oauth_only_user(client, test_db): + """OAuth-only users get the generic message; no email is sent.""" + await _make_oauth_only_user(test_db, "oauth-forgot@example.com", with_identity=False) + from unittest.mock import AsyncMock, patch + with patch("app.core.email.EmailService.send_password_reset_email", new_callable=AsyncMock) as mock_send: + response = await client.post( + "/api/v1/auth/password/forgot", + json={"email": "oauth-forgot@example.com"}, + ) + assert response.status_code == 200 + mock_send.assert_not_called() + + +@pytest.mark.asyncio +async def test_login_for_password_user_still_works(client, test_user): + """Regression: existing password-based login must still succeed.""" + response = await client.post( + "/api/v1/auth/login/json", + json={"email": test_user["email"], "password": test_user["password"]}, + ) + assert response.status_code == 200 + assert response.json()["access_token"] diff --git a/backend/tests/test_pilot_complimentary_backfill.py b/backend/tests/test_pilot_complimentary_backfill.py new file mode 100644 index 00000000..70deb48d --- /dev/null +++ b/backend/tests/test_pilot_complimentary_backfill.py @@ -0,0 +1,85 @@ +"""Smoke test for the complimentary backfill: assertions about the post-state. +The actual migration runs at deploy time; tests use create_all so the +migration body isn't executed automatically. We invoke the SQL inline to +exercise the same effect.""" +import uuid +import pytest +from sqlalchemy import select, text, delete +from app.models.account import Account +from app.models.subscription import Subscription + + +@pytest.mark.asyncio +async def test_complimentary_backfill_sets_status_and_inserts_missing_rows(test_db): + """Inline-run the backfill SQL and assert post-state.""" + # Seed a fresh account with no subscription + no_sub_account = Account(name="NoSub", display_code="NOSUB001") + test_db.add(no_sub_account) + await test_db.flush() + + # Seed an account with a trialing subscription (should become complimentary) + trial_account = Account(name="Trial", display_code="TRIAL001") + test_db.add(trial_account) + await test_db.flush() + test_db.add(Subscription( + account_id=trial_account.id, plan="free", status="trialing", + )) + + # Seed an account with a canceled subscription (should be preserved) + canceled_account = Account(name="Cancel", display_code="CANCL001") + test_db.add(canceled_account) + await test_db.flush() + test_db.add(Subscription( + account_id=canceled_account.id, plan="pro", status="canceled", + )) + await test_db.commit() + + # Run the same SQL the migration runs + await test_db.execute(text(""" + UPDATE subscriptions + SET status = 'complimentary', plan = 'pro', + current_period_end = NULL, current_period_start = NULL, + updated_at = now() + WHERE status NOT IN ('canceled', 'past_due') + """)) + await test_db.execute(text(""" + INSERT INTO subscriptions (id, account_id, plan, status, cancel_at_period_end, created_at, updated_at) + SELECT gen_random_uuid(), a.id, 'pro', 'complimentary', false, now(), now() + FROM accounts a + WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.account_id = a.id) + """)) + await test_db.commit() + + # All three accounts now have a Subscription + no_sub_row = (await test_db.execute( + select(Subscription).where(Subscription.account_id == no_sub_account.id) + )).scalar_one() + assert no_sub_row.status == "complimentary" + assert no_sub_row.plan == "pro" + + trial_row = (await test_db.execute( + select(Subscription).where(Subscription.account_id == trial_account.id) + )).scalar_one() + assert trial_row.status == "complimentary" + assert trial_row.plan == "pro" + + canceled_row = (await test_db.execute( + select(Subscription).where(Subscription.account_id == canceled_account.id) + )).scalar_one() + # Canceled is preserved + assert canceled_row.status == "canceled" + assert canceled_row.plan == "pro" + + +@pytest.mark.asyncio +async def test_complimentary_subscription_passes_active_subscription_guard( + client, test_db, test_user, auth_headers +): + """The require_active_subscription guard accepts complimentary status.""" + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id)) + test_db.add(Subscription(account_id=account_id, plan="pro", status="complimentary")) + await test_db.commit() + + response = await client.get("/api/v1/trees", headers=auth_headers) + assert response.status_code != 402 diff --git a/backend/tests/test_stripe_webhook_handler.py b/backend/tests/test_stripe_webhook_handler.py new file mode 100644 index 00000000..0430b9f3 --- /dev/null +++ b/backend/tests/test_stripe_webhook_handler.py @@ -0,0 +1,144 @@ +import json +import uuid +import pytest +from sqlalchemy import delete, select +from unittest.mock import patch +from app.models.subscription import Subscription + + +def _make_event(event_id, event_type, obj): + return { + "id": event_id, + "type": event_type, + "data": {"object": obj}, + } + + +@pytest.mark.asyncio +async def test_checkout_completed_activates_subscription( + client, test_db, test_user, auth_headers, monkeypatch +): + from app.core.config import settings + monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy") + monkeypatch.setattr(settings, "STRIPE_WEBHOOK_SECRET", "whsec_dummy") + + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + # Replace seeded sub with trialing + stripe_customer_id linkage + from app.models.account import Account + account = (await test_db.execute(select(Account).where(Account.id == account_id))).scalar_one() + account.stripe_customer_id = "cus_xxx" + await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id)) + test_db.add(Subscription(account_id=account_id, plan="pro", status="trialing")) + await test_db.commit() + + event = _make_event("evt_co_1", "checkout.session.completed", { + "id": "cs_xxx", + "customer": "cus_xxx", + "subscription": "sub_xxx", + }) + + with patch("stripe.Subscription.retrieve", return_value={ + "id": "sub_xxx", + "status": "active", + "current_period_start": 1714521600, + "current_period_end": 1717113600, + "items": {"data": [{ + "price": {"id": "price_test_monthly"}, + "quantity": 5, + }]}, + "cancel_at_period_end": False, + }), patch("stripe.Webhook.construct_event", return_value=event): + response = await client.post( + "/api/v1/webhooks/stripe", + content=json.dumps(event), + headers={"stripe-signature": "fake-sig"}, + ) + assert response.status_code == 200, response.json() + + sub = (await test_db.execute( + select(Subscription).where(Subscription.account_id == account_id) + )).scalar_one() + assert sub.status == "active" + assert sub.stripe_subscription_id == "sub_xxx" + + +@pytest.mark.asyncio +async def test_subscription_deleted_cancels_account( + client, test_db, test_user, auth_headers, monkeypatch +): + from app.core.config import settings + monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy") + monkeypatch.setattr(settings, "STRIPE_WEBHOOK_SECRET", "whsec_dummy") + + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id)) + test_db.add(Subscription( + account_id=account_id, plan="pro", status="active", + stripe_subscription_id="sub_xxx", + )) + await test_db.commit() + + event = _make_event("evt_del_1", "customer.subscription.deleted", { + "id": "sub_xxx", + "current_period_start": 1714521600, + "current_period_end": 1717113600, + "items": {"data": [{"quantity": 1}]}, + }) + + with patch("stripe.Webhook.construct_event", return_value=event): + response = await client.post( + "/api/v1/webhooks/stripe", + content=json.dumps(event), + headers={"stripe-signature": "fake-sig"}, + ) + assert response.status_code == 200 + + sub = (await test_db.execute( + select(Subscription).where(Subscription.account_id == account_id) + )).scalar_one() + assert sub.status == "canceled" + + +@pytest.mark.asyncio +async def test_webhook_signature_failure_returns_400(client, monkeypatch): + from app.core.config import settings + monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy") + monkeypatch.setattr(settings, "STRIPE_WEBHOOK_SECRET", "whsec_dummy") + + with patch("stripe.Webhook.construct_event", side_effect=ValueError("bad sig")): + response = await client.post( + "/api/v1/webhooks/stripe", + content=b"{}", + headers={"stripe-signature": "fake-sig"}, + ) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_webhook_idempotency( + client, test_db, test_user, auth_headers, monkeypatch +): + from app.core.config import settings + monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy") + monkeypatch.setattr(settings, "STRIPE_WEBHOOK_SECRET", "whsec_dummy") + + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id)) + test_db.add(Subscription(account_id=account_id, plan="pro", status="trialing")) + await test_db.commit() + + event = _make_event("evt_dup_1", "customer.subscription.updated", { + "id": "sub_yyy", + "status": "active", + "current_period_start": 1714521600, + "current_period_end": 1717113600, + "items": {"data": [{"quantity": 1}]}, + "cancel_at_period_end": False, + }) + with patch("stripe.Webhook.construct_event", return_value=event): + r1 = await client.post("/api/v1/webhooks/stripe", content=json.dumps(event), headers={"stripe-signature": "x"}) + r2 = await client.post("/api/v1/webhooks/stripe", content=json.dumps(event), headers={"stripe-signature": "x"}) + assert r1.status_code == 200 + assert r2.status_code == 200 + assert r1.json()["applied"] is True + assert r2.json()["applied"] is False diff --git a/backend/tests/test_subscription_guards.py b/backend/tests/test_subscription_guards.py new file mode 100644 index 00000000..ab07f146 --- /dev/null +++ b/backend/tests/test_subscription_guards.py @@ -0,0 +1,89 @@ +"""Tests for require_active_subscription dependency. + +Verifies the 402 gating logic for Pro-guarded routers and the allowlist +that lets billing/account/auth flows through even when locked. +""" + +import uuid +import pytest +from datetime import datetime, timezone, timedelta +from sqlalchemy import delete +from app.models.subscription import Subscription + + +async def _set_subscription(test_db, account_id, **fields): + """Replace any existing Subscription on the account with one matching `fields`.""" + await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id)) + test_db.add(Subscription(account_id=account_id, **fields)) + await test_db.commit() + + +@pytest.mark.asyncio +async def test_active_subscription_passes(client, test_db, test_user, auth_headers): + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + await _set_subscription(test_db, account_id, plan="pro", status="active") + response = await client.get("/api/v1/trees", headers=auth_headers) + assert response.status_code != 402 + + +@pytest.mark.asyncio +async def test_complimentary_subscription_passes(client, test_db, test_user, auth_headers): + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + await _set_subscription(test_db, account_id, plan="pro", status="complimentary") + response = await client.get("/api/v1/trees", headers=auth_headers) + assert response.status_code != 402 + + +@pytest.mark.asyncio +async def test_trialing_unexpired_passes(client, test_db, test_user, auth_headers): + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + await _set_subscription( + test_db, account_id, + plan="pro", status="trialing", + current_period_end=datetime.now(timezone.utc) + timedelta(days=5), + ) + response = await client.get("/api/v1/trees", headers=auth_headers) + assert response.status_code != 402 + + +@pytest.mark.asyncio +async def test_trialing_expired_returns_402(client, test_db, test_user, auth_headers): + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + await _set_subscription( + test_db, account_id, + plan="pro", status="trialing", + current_period_end=datetime.now(timezone.utc) - timedelta(hours=1), + ) + response = await client.get("/api/v1/trees", headers=auth_headers) + assert response.status_code == 402 + body = response.json() + assert body["detail"]["error"] == "subscription_inactive" + + +@pytest.mark.asyncio +async def test_canceled_returns_402(client, test_db, test_user, auth_headers): + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + await _set_subscription(test_db, account_id, plan="pro", status="canceled") + response = await client.get("/api/v1/trees", headers=auth_headers) + assert response.status_code == 402 + + +@pytest.mark.asyncio +async def test_no_subscription_returns_402(client, test_db, test_user, auth_headers): + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + # Remove the seeded default subscription + await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id)) + await test_db.commit() + response = await client.get("/api/v1/trees", headers=auth_headers) + assert response.status_code == 402 + body = response.json() + assert body["detail"]["error"] == "no_subscription" + + +@pytest.mark.asyncio +async def test_auth_me_bypasses_guard(client, test_db, test_user, auth_headers): + """Allowlisted route works even when subscription is canceled.""" + account_id = uuid.UUID(test_user["user_data"]["account_id"]) + await _set_subscription(test_db, account_id, plan="pro", status="canceled") + response = await client.get("/api/v1/auth/me", headers=auth_headers) + assert response.status_code == 200 diff --git a/backend/tests/test_subscription_limits.py b/backend/tests/test_subscription_limits.py index 6f07266f..196861e7 100644 --- a/backend/tests/test_subscription_limits.py +++ b/backend/tests/test_subscription_limits.py @@ -10,8 +10,15 @@ class TestSubscriptionLimits: """Test suite for subscription plan limits.""" @pytest.mark.asyncio - async def test_free_plan_tree_limit(self, client: AsyncClient, auth_headers: dict): + async def test_free_plan_tree_limit( + self, client: AsyncClient, auth_headers: dict, test_db: AsyncSession + ): """Test that free plan has tree creation limit of 3.""" + from app.models.subscription import Subscription + sub = (await test_db.execute(select(Subscription))).scalar_one() + sub.plan = "free" + await test_db.commit() + tree_template = { "name": "Limit Test Tree", "tree_structure": { @@ -90,8 +97,15 @@ class TestSubscriptionLimits: assert response.status_code == 201 @pytest.mark.asyncio - async def test_free_plan_limits_correct(self, client: AsyncClient, auth_headers: dict): + async def test_free_plan_limits_correct( + self, client: AsyncClient, auth_headers: dict, test_db: AsyncSession + ): """Test that free plan limits are correct.""" + from app.models.subscription import Subscription + sub = (await test_db.execute(select(Subscription))).scalar_one() + sub.plan = "free" + await test_db.commit() + response = await client.get("/api/v1/accounts/me/subscription", headers=auth_headers) assert response.status_code == 200 limits = response.json()["limits"] diff --git a/backend/tests/test_subscription_properties.py b/backend/tests/test_subscription_properties.py new file mode 100644 index 00000000..77d5716c --- /dev/null +++ b/backend/tests/test_subscription_properties.py @@ -0,0 +1,41 @@ +from datetime import datetime, timezone, timedelta +from app.models.subscription import Subscription + + +def make_sub(**kwargs): + sub = Subscription() + sub.plan = kwargs.get("plan", "free") + sub.status = kwargs.get("status", "active") + sub.current_period_end = kwargs.get("current_period_end") + return sub + + +def test_complimentary_is_active_but_not_paid(): + sub = make_sub(plan="pro", status="complimentary") + assert sub.is_active is True + assert sub.is_paid is False + assert sub.has_pro_entitlement is True + + +def test_paid_pro_active(): + sub = make_sub(plan="pro", status="active") + assert sub.is_paid is True + assert sub.has_pro_entitlement is True + + +def test_trial_unexpired_has_entitlement(): + sub = make_sub(plan="pro", status="trialing", current_period_end=datetime.now(timezone.utc) + timedelta(days=5)) + assert sub.is_active is True + assert sub.is_paid is False + assert sub.has_pro_entitlement is True + + +def test_trial_expired_no_entitlement(): + sub = make_sub(plan="pro", status="trialing", current_period_end=datetime.now(timezone.utc) - timedelta(hours=1)) + assert sub.has_pro_entitlement is False + + +def test_canceled_no_entitlement(): + sub = make_sub(plan="pro", status="canceled") + assert sub.is_active is False + assert sub.has_pro_entitlement is False diff --git a/backend/tests/test_tenant_isolation_p0.py b/backend/tests/test_tenant_isolation_p0.py index 4ef9729f..dfa70f7e 100644 --- a/backend/tests/test_tenant_isolation_p0.py +++ b/backend/tests/test_tenant_isolation_p0.py @@ -12,13 +12,18 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.account import Account from app.models.user import User from app.models.tree import Tree +from app.models.subscription import Subscription from app.core.security import get_password_hash # ── Helpers ────────────────────────────────────────────────────────────────── async def _create_account_and_user(db: AsyncSession, prefix: str): - """Create a fresh account + engineer user. Returns (account, user, plain_password).""" + """Create a fresh account + engineer user. Returns (account, user, plain_password). + + Seeds a default active Pro Subscription for the account so requests pass + the require_active_subscription guard added in Phase 1 Task 11. + """ password = "TestPass123!" account = Account( name=f"{prefix}-corp", @@ -36,6 +41,7 @@ async def _create_account_and_user(db: AsyncSession, prefix: str): account_role="engineer", ) db.add(user) + db.add(Subscription(account_id=account.id, plan="pro", status="active")) await db.flush() return account, user, password @@ -168,6 +174,7 @@ async def test_ai_session_search_cannot_see_other_users_sessions( account = Account(name="Shared Corp", display_code=uuid.uuid4().hex[:8]) test_db.add(account) await test_db.flush() + test_db.add(Subscription(account_id=account.id, plan="pro", status="active")) password = "TestPass123!" user_a = User( diff --git a/backend/tests/test_user_password_nullable.py b/backend/tests/test_user_password_nullable.py new file mode 100644 index 00000000..6e13cdd0 --- /dev/null +++ b/backend/tests/test_user_password_nullable.py @@ -0,0 +1,23 @@ +import pytest +from app.models.user import User +from app.models.account import Account + + +@pytest.mark.asyncio +async def test_user_can_be_created_without_password_hash(test_db): + """OAuth-only users have password_hash=None and the row should commit cleanly.""" + account = Account(name="OAuthShop", display_code="OAUTH001") + test_db.add(account) + await test_db.flush() + + user = User( + email="oauth-only@example.com", + name="OAuth Only", + password_hash=None, + account_id=account.id, + account_role="engineer", + ) + test_db.add(user) + await test_db.commit() + await test_db.refresh(user) + assert user.password_hash is None diff --git a/docs/superpowers/plans/2026-05-06-self-serve-signup-phase-1-backend.md b/docs/superpowers/plans/2026-05-06-self-serve-signup-phase-1-backend.md new file mode 100644 index 00000000..113e2250 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-self-serve-signup-phase-1-backend.md @@ -0,0 +1,3163 @@ +# Self-Serve Signup & Onboarding — Phase 1: Backend Foundation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the backend foundation for public self-serve signup — schema additions, billing service, Stripe webhook event handling, OAuth provider callbacks, email-verification auto-send, account-invite extensions, `/billing/state` endpoint, and pilot user backfill — all dark behind `SELF_SERVE_ENABLED` until Phase 2 ships the frontend. + +**Architecture:** Reuses existing `subscriptions` / `plan_limits` / `feature_flags` / `account_invites` / `email_verification_tokens` infrastructure. New schema is small: `oauth_identities`, `plan_billing` (sibling to `plan_limits`), `sales_leads`, `stripe_events`. Replaces `deps.py:109` trial auto-downgrade with two new non-mutating dependencies (`require_active_subscription`, `require_verified_email_after_grace`). Adds `BillingService` module wrapping Stripe; webhook handler extends the existing stub at `app/api/endpoints/webhooks.py`. + +**Tech Stack:** Python 3.11 + FastAPI, SQLAlchemy 2.0 async, Alembic, Pydantic v2, Stripe Python SDK, `respx` for HTTP mocking in tests, `python-jose` for JWT, `bcrypt`, existing `EmailService`. + +**Spec reference:** `docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md` (commit `bbb01ef`). + +--- + +## File Structure + +### New backend files + +``` +backend/app/models/oauth_identity.py +backend/app/models/plan_billing.py +backend/app/models/sales_lead.py +backend/app/models/stripe_event.py +backend/app/schemas/billing.py +backend/app/schemas/oauth.py +backend/app/services/billing.py +backend/app/services/oauth_providers.py +backend/app/api/endpoints/billing.py +backend/app/api/endpoints/oauth.py +backend/alembic/versions/_add_oauth_identities.py +backend/alembic/versions/_users_password_hash_nullable.py +backend/alembic/versions/_users_add_role_at_signup_and_onboarding_step.py +backend/alembic/versions/_accounts_add_wizard_columns.py +backend/alembic/versions/_account_invites_add_revoked_and_email_sent.py +backend/alembic/versions/_add_plan_billing.py +backend/alembic/versions/_add_sales_leads.py +backend/alembic/versions/_add_stripe_events.py +backend/alembic/versions/_subscriptions_pilot_complimentary_backfill.py +backend/tests/test_billing_service.py +backend/tests/test_subscription_guards.py +backend/tests/test_oauth_callbacks.py +backend/tests/test_account_invite_extensions.py +backend/tests/test_email_verification_autosend.py +backend/tests/test_stripe_webhook_handler.py +backend/tests/test_billing_state_endpoint.py +``` + +### Modified backend files + +``` +backend/app/models/subscription.py — add 'complimentary' status, fix is_paid, add has_pro_entitlement +backend/app/models/user.py — password_hash nullable, role_at_signup, onboarding_step_completed +backend/app/models/account.py — team_size_bucket, primary_psa +backend/app/models/account_invite.py — revoked_at, email_sent_at, property updates +backend/app/api/deps.py — REMOVE auto-downgrade block, add new deps +backend/app/api/endpoints/auth.py — auto-send verify email on register; email-match enforcement; null password_hash handling +backend/app/api/endpoints/accounts.py — wire EmailService.send_account_invite_email; bulk endpoint; soft-revoke +backend/app/api/endpoints/webhooks.py — extend stub with concrete event handlers +backend/app/api/router.py — register new oauth + billing routers +backend/app/schemas/user.py — preserve UserCreate.account_invite_code; no plan/billing additions +backend/app/core/config.py — SELF_SERVE_ENABLED flag, OAUTH_REDIRECT_BASE, GOOGLE_CLIENT_ID/SECRET, MS_CLIENT_ID/SECRET +``` + +### Phase boundaries + +| Phase | Tasks | Outcome | +|---|---|---| +| A | 1–8 | Schema is in place; tests pass | +| B | 9–12 | Subscription model + new guards; old auto-downgrade gone | +| C | 13–16 | BillingService + webhook handler complete | +| D | 17–19 | Google + Microsoft OAuth working end-to-end | +| E | 20 | Auto-send verification + email-match enforcement | +| F | 21–23 | Account-invite send + bulk + revoke routes | +| G | 24 | `/billing/state` endpoint live | +| H | 25 | Pilot user backfill applied | + +Each phase ends with a clean test run + commit; no phase leaves the backend in a broken state. + +--- + +## Phase A — Schema migrations + +### Task 1: Add oauth_identities table + +**Files:** +- Create: `backend/app/models/oauth_identity.py` +- Create: `backend/alembic/versions/_add_oauth_identities.py` +- Modify: `backend/app/models/__init__.py` (register import) +- Test: `backend/tests/test_oauth_identity_model.py` + +- [ ] **Step 1: Create the model file** + +```python +# backend/app/models/oauth_identity.py +import uuid +from datetime import datetime, timezone +from typing import TYPE_CHECKING +from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + +if TYPE_CHECKING: + from app.models.user import User + + +class OAuthIdentity(Base): + __tablename__ = "oauth_identities" + __table_args__ = ( + UniqueConstraint("provider", "provider_subject", name="uq_oauth_identities_provider_subject"), + Index("ix_oauth_identities_user_id", "user_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + provider: Mapped[str] = mapped_column(String(20), nullable=False) + provider_subject: Mapped[str] = mapped_column(String(255), nullable=False) + provider_email_at_link: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + user: Mapped["User"] = relationship("User", backref="oauth_identities") +``` + +- [ ] **Step 2: Register in models __init__** + +```python +# backend/app/models/__init__.py — add line alongside other imports: +from app.models.oauth_identity import OAuthIdentity # noqa: F401 +``` + +- [ ] **Step 3: Create the migration** + +```bash +docker exec -w /app resolutionflow_backend alembic revision -m "add oauth_identities" +``` + +Edit the generated file: + +```python +# backend/alembic/versions/_add_oauth_identities.py +"""add oauth_identities + +Revision ID: +Revises: +Create Date: 2026-05-06 ... + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +revision = "" +down_revision = "" # set to whichever existing head this branches from +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "oauth_identities", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("provider", sa.String(20), nullable=False), + sa.Column("provider_subject", sa.String(255), nullable=False), + sa.Column("provider_email_at_link", sa.String(255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("provider", "provider_subject", name="uq_oauth_identities_provider_subject"), + ) + op.create_index("ix_oauth_identities_user_id", "oauth_identities", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_oauth_identities_user_id", table_name="oauth_identities") + op.drop_table("oauth_identities") +``` + +- [ ] **Step 4: Apply migration** + +Run: `docker exec resolutionflow_backend alembic upgrade heads` +Expected: applies cleanly; output includes the new revision id. + +- [ ] **Step 5: Write a model smoke test** + +```python +# backend/tests/test_oauth_identity_model.py +import pytest +from sqlalchemy import select +from app.models.oauth_identity import OAuthIdentity + + +@pytest.mark.asyncio +async def test_oauth_identity_unique_provider_subject(db_session, test_user): + """Two rows with same provider+subject should violate uniqueness.""" + row1 = OAuthIdentity( + user_id=test_user.id, + provider="google", + provider_subject="abc-123", + provider_email_at_link="alex@acmemsp.com", + ) + db_session.add(row1) + await db_session.commit() + + row2 = OAuthIdentity( + user_id=test_user.id, + provider="google", + provider_subject="abc-123", + provider_email_at_link="alex@acmemsp.com", + ) + db_session.add(row2) + with pytest.raises(Exception): # IntegrityError + await db_session.commit() + await db_session.rollback() + + rows = (await db_session.execute(select(OAuthIdentity).where(OAuthIdentity.user_id == test_user.id))).scalars().all() + assert len(rows) == 1 +``` + +- [ ] **Step 6: Run the test** + +Run: `docker exec resolutionflow_backend pytest backend/tests/test_oauth_identity_model.py -v --override-ini="addopts="` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/models/oauth_identity.py backend/app/models/__init__.py backend/alembic/versions/*_add_oauth_identities.py backend/tests/test_oauth_identity_model.py +git commit -m "feat(auth): add oauth_identities table for Google/Microsoft sign-in" +``` + +--- + +### Task 2: Make users.password_hash nullable + +**Files:** +- Modify: `backend/app/models/user.py:36` +- Create: `backend/alembic/versions/_users_password_hash_nullable.py` + +- [ ] **Step 1: Update the model** + +Change `backend/app/models/user.py` line 36 from: + +```python +password_hash: Mapped[str] = mapped_column(String(255), nullable=False) +``` + +to: + +```python +password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) +``` + +- [ ] **Step 2: Create the migration** + +```bash +docker exec -w /app resolutionflow_backend alembic revision -m "users password_hash nullable" +``` + +Edit: + +```python +def upgrade() -> None: + op.alter_column("users", "password_hash", nullable=True) + + +def downgrade() -> None: + # NOTE: downgrade is non-trivial if any OAuth-only users exist. + # This downgrade fails fast in that case rather than corrupting data. + conn = op.get_bind() + null_count = conn.execute(sa.text("SELECT COUNT(*) FROM users WHERE password_hash IS NULL")).scalar() + if null_count and null_count > 0: + raise RuntimeError( + f"Cannot downgrade: {null_count} OAuth-only users have NULL password_hash. " + "Set passwords or delete those rows before downgrading." + ) + op.alter_column("users", "password_hash", nullable=False) +``` + +- [ ] **Step 3: Apply** + +Run: `docker exec resolutionflow_backend alembic upgrade heads` +Expected: applies cleanly. + +- [ ] **Step 4: Verify with a smoke test** + +```python +# backend/tests/test_user_password_nullable.py +import pytest +from app.models.user import User + + +@pytest.mark.asyncio +async def test_user_can_be_created_without_password_hash(db_session, test_account): + user = User( + email="oauth-only@example.com", + name="OAuth Only", + password_hash=None, + account_id=test_account.id, + account_role="engineer", + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + assert user.password_hash is None +``` + +- [ ] **Step 5: Run the test** + +Run: `docker exec resolutionflow_backend pytest backend/tests/test_user_password_nullable.py -v --override-ini="addopts="` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/models/user.py backend/alembic/versions/*_users_password_hash_nullable.py backend/tests/test_user_password_nullable.py +git commit -m "feat(auth): make users.password_hash nullable for OAuth-only accounts" +``` + +--- + +### Task 3: Add users.role_at_signup + users.onboarding_step_completed + +**Files:** +- Modify: `backend/app/models/user.py` +- Create: `backend/alembic/versions/_users_add_role_at_signup_and_onboarding_step.py` + +- [ ] **Step 1: Add columns to model** + +After the `onboarding_dismissed` column in `backend/app/models/user.py` (around line 78), add: + +```python + role_at_signup: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + onboarding_step_completed: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) +``` + +Make sure `Integer` is imported at top: + +```python +from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text, Integer +``` + +- [ ] **Step 2: Create migration** + +```bash +docker exec -w /app resolutionflow_backend alembic revision -m "users add role_at_signup and onboarding_step_completed" +``` + +```python +def upgrade() -> None: + op.add_column("users", sa.Column("role_at_signup", sa.String(50), nullable=True)) + op.add_column("users", sa.Column("onboarding_step_completed", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "onboarding_step_completed") + op.drop_column("users", "role_at_signup") +``` + +- [ ] **Step 3: Apply** + +Run: `docker exec resolutionflow_backend alembic upgrade heads` +Expected: applies cleanly. + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/models/user.py backend/alembic/versions/*_users_add_role_at_signup*.py +git commit -m "feat(onboarding): add users.role_at_signup and onboarding_step_completed" +``` + +--- + +### Task 4: Add accounts.team_size_bucket + accounts.primary_psa + +**Files:** +- Modify: `backend/app/models/account.py` +- Create: `backend/alembic/versions/_accounts_add_wizard_columns.py` + +- [ ] **Step 1: Add columns to model** + +In `backend/app/models/account.py`, after `branding_company_name` (around line 50), add: + +```python + team_size_bucket: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) + primary_psa: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) +``` + +- [ ] **Step 2: Create migration** + +```bash +docker exec -w /app resolutionflow_backend alembic revision -m "accounts add wizard columns" +``` + +```python +def upgrade() -> None: + op.add_column("accounts", sa.Column("team_size_bucket", sa.String(20), nullable=True)) + op.add_column("accounts", sa.Column("primary_psa", sa.String(20), nullable=True)) + + +def downgrade() -> None: + op.drop_column("accounts", "primary_psa") + op.drop_column("accounts", "team_size_bucket") +``` + +- [ ] **Step 3: Apply + commit** + +```bash +docker exec resolutionflow_backend alembic upgrade heads +git add backend/app/models/account.py backend/alembic/versions/*_accounts_add_wizard_columns.py +git commit -m "feat(onboarding): add accounts.team_size_bucket and primary_psa for wizard" +``` + +--- + +### Task 5: Add account_invites.revoked_at + email_sent_at + +**Files:** +- Modify: `backend/app/models/account_invite.py` +- Create: `backend/alembic/versions/_account_invites_add_revoked_and_email_sent.py` +- Test: `backend/tests/test_account_invite_model.py` + +- [ ] **Step 1: Add columns and update properties** + +In `backend/app/models/account_invite.py`: + +```python + revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + email_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) +``` + +Update properties: + +```python + @property + def is_used(self) -> bool: + return self.accepted_by_id is not None + + @property + def is_revoked(self) -> bool: + return self.revoked_at is not None + + @property + def is_expired(self) -> bool: + if self.expires_at is None: + return False + return datetime.now(timezone.utc) > self.expires_at + + @property + def is_valid(self) -> bool: + return not self.is_used and not self.is_expired and not self.is_revoked +``` + +- [ ] **Step 2: Migration** + +```bash +docker exec -w /app resolutionflow_backend alembic revision -m "account_invites add revoked_at and email_sent_at" +``` + +```python +def upgrade() -> None: + op.add_column("account_invites", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("account_invites", sa.Column("email_sent_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index("ix_account_invites_revoked_at", "account_invites", ["revoked_at"]) + + +def downgrade() -> None: + op.drop_index("ix_account_invites_revoked_at", table_name="account_invites") + op.drop_column("account_invites", "email_sent_at") + op.drop_column("account_invites", "revoked_at") +``` + +- [ ] **Step 3: Property test** + +```python +# backend/tests/test_account_invite_model.py +import pytest +from datetime import datetime, timezone, timedelta +from app.models.account_invite import AccountInvite + + +def make_invite(**kwargs): + return AccountInvite( + account_id=kwargs.get("account_id", "00000000-0000-0000-0000-000000000001"), + invited_by_id=kwargs.get("invited_by_id", "00000000-0000-0000-0000-000000000002"), + email=kwargs.get("email", "x@y.com"), + code=kwargs.get("code", "ABCD1234"), + role=kwargs.get("role", "engineer"), + accepted_by_id=kwargs.get("accepted_by_id"), + expires_at=kwargs.get("expires_at"), + revoked_at=kwargs.get("revoked_at"), + ) + + +def test_invite_revoked_is_invalid(): + invite = make_invite(revoked_at=datetime.now(timezone.utc)) + assert invite.is_revoked is True + assert invite.is_valid is False + + +def test_invite_unrevoked_unexpired_unused_is_valid(): + invite = make_invite(expires_at=datetime.now(timezone.utc) + timedelta(days=7)) + assert invite.is_valid is True +``` + +- [ ] **Step 4: Run test + apply migration** + +```bash +docker exec resolutionflow_backend alembic upgrade heads +docker exec resolutionflow_backend pytest backend/tests/test_account_invite_model.py -v --override-ini="addopts=" +``` + +Expected: tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/models/account_invite.py backend/alembic/versions/*_account_invites_add_*.py backend/tests/test_account_invite_model.py +git commit -m "feat(invites): add revoked_at + email_sent_at to account_invites" +``` + +--- + +### Task 6: Add plan_billing table + +**Files:** +- Create: `backend/app/models/plan_billing.py` +- Create: `backend/alembic/versions/_add_plan_billing.py` +- Modify: `backend/app/models/__init__.py` + +- [ ] **Step 1: Model** + +```python +# backend/app/models/plan_billing.py +from datetime import datetime, timezone +from typing import Optional +from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Text +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class PlanBilling(Base): + __tablename__ = "plan_billing" + + plan: Mapped[str] = mapped_column( + String(50), ForeignKey("plan_limits.plan"), primary_key=True + ) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + monthly_price_cents: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + annual_price_cents: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + stripe_product_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + stripe_monthly_price_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + stripe_annual_price_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) +``` + +Register in `__init__.py`: +```python +from app.models.plan_billing import PlanBilling # noqa: F401 +``` + +- [ ] **Step 2: Migration** + +```bash +docker exec -w /app resolutionflow_backend alembic revision -m "add plan_billing" +``` + +```python +def upgrade() -> None: + op.create_table( + "plan_billing", + sa.Column("plan", sa.String(50), sa.ForeignKey("plan_limits.plan"), primary_key=True), + sa.Column("display_name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("monthly_price_cents", sa.Integer(), nullable=True), + sa.Column("annual_price_cents", sa.Integer(), nullable=True), + sa.Column("stripe_product_id", sa.String(255), nullable=True), + sa.Column("stripe_monthly_price_id", sa.String(255), nullable=True), + sa.Column("stripe_annual_price_id", sa.String(255), nullable=True), + sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("plan_billing") +``` + +Note: this migration creates the table empty. Stripe IDs and per-environment seeding happens later (out-of-band, via `/admin/plan-limits` PUT once that endpoint accepts plan_billing fields, or a one-off `scripts.sync_stripe_plan_ids` admin command). The migration stays environment-agnostic. + +- [ ] **Step 3: Apply + commit** + +```bash +docker exec resolutionflow_backend alembic upgrade heads +git add backend/app/models/plan_billing.py backend/app/models/__init__.py backend/alembic/versions/*_add_plan_billing.py +git commit -m "feat(billing): add plan_billing sibling table for Stripe + catalog metadata" +``` + +--- + +### Task 7: Add sales_leads + stripe_events tables + +**Files:** +- Create: `backend/app/models/sales_lead.py` +- Create: `backend/app/models/stripe_event.py` +- Create: `backend/alembic/versions/_add_sales_leads.py` +- Create: `backend/alembic/versions/_add_stripe_events.py` +- Modify: `backend/app/models/__init__.py` + +- [ ] **Step 1: SalesLead model** + +```python +# backend/app/models/sales_lead.py +import uuid +from datetime import datetime, timezone +from typing import Optional +from sqlalchemy import String, DateTime, Text, Index +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + + +class SalesLead(Base): + __tablename__ = "sales_leads" + __table_args__ = (Index("ix_sales_leads_email", "email"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + company: Mapped[str] = mapped_column(String(255), nullable=False) + team_size: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) + message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + source: Mapped[str] = mapped_column(String(50), nullable=False) + posthog_distinct_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="new") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) +``` + +- [ ] **Step 2: StripeEvent model** + +```python +# backend/app/models/stripe_event.py +from datetime import datetime, timezone +from sqlalchemy import String, DateTime, Index +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import JSONB +from app.core.database import Base + + +class StripeEvent(Base): + __tablename__ = "stripe_events" + __table_args__ = (Index("ix_stripe_events_event_type", "event_type"),) + + id: Mapped[str] = mapped_column(String(255), primary_key=True) # Stripe event id + event_type: Mapped[str] = mapped_column(String(100), nullable=False) + processed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + payload_excerpt: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) +``` + +Register both in `__init__.py`. + +- [ ] **Step 3: Two migrations** + +```bash +docker exec -w /app resolutionflow_backend alembic revision -m "add sales_leads" +docker exec -w /app resolutionflow_backend alembic revision -m "add stripe_events" +``` + +Each migration creates its respective table — fields match the models above. + +- [ ] **Step 4: Apply + commit** + +```bash +docker exec resolutionflow_backend alembic upgrade heads +git add backend/app/models/sales_lead.py backend/app/models/stripe_event.py backend/app/models/__init__.py backend/alembic/versions/*_add_sales_leads.py backend/alembic/versions/*_add_stripe_events.py +git commit -m "feat(billing): add sales_leads and stripe_events tables" +``` + +--- + +### Task 8: Verify migrations apply on a fresh DB + +**Files:** None (verification step). + +- [ ] **Step 1: Drop test DB and recreate** + +```bash +docker exec resolutionflow_postgres psql -U postgres -c "DROP DATABASE IF EXISTS resolutionflow_test;" +docker exec resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;" +``` + +- [ ] **Step 2: Apply all migrations to fresh DB** + +```bash +docker exec -e DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test resolutionflow_backend alembic upgrade heads +``` + +Expected: all migrations apply cleanly through to the new heads. No errors. + +- [ ] **Step 3: Run full backend test suite** + +```bash +docker exec resolutionflow_backend pytest --override-ini="addopts=" +``` + +Expected: all existing tests still pass. New model tests added in Tasks 1-7 also pass. + +- [ ] **Step 4: No commit (verification only)** + +If the suite is green, Phase A is complete. + +--- + +## Phase B — Subscription model + new dependencies + +### Task 9: Add 'complimentary' status; fix is_paid; add has_pro_entitlement + +**Files:** +- Modify: `backend/app/models/subscription.py` +- Test: `backend/tests/test_subscription_properties.py` + +- [ ] **Step 1: Update model properties** + +Replace the property block at the bottom of `backend/app/models/subscription.py`: + +```python + @property + def is_active(self) -> bool: + return self.status in ("active", "trialing", "complimentary") + + @property + def is_paid(self) -> bool: + # Excludes complimentary so MRR/paid-customer metrics aren't inflated. + return self.plan in ("pro", "team") and self.status not in ("complimentary",) + + @property + def has_pro_entitlement(self) -> bool: + """True if the account can access Pro features right now.""" + if self.plan in ("pro", "team"): + if self.status in ("active", "complimentary"): + return True + if self.status == "trialing" and self.current_period_end is not None: + from datetime import datetime, timezone + return self.current_period_end > datetime.now(timezone.utc) + return False +``` + +- [ ] **Step 2: Property tests** + +```python +# backend/tests/test_subscription_properties.py +from datetime import datetime, timezone, timedelta +from app.models.subscription import Subscription + + +def make_sub(**kwargs): + sub = Subscription() + sub.plan = kwargs.get("plan", "free") + sub.status = kwargs.get("status", "active") + sub.current_period_end = kwargs.get("current_period_end") + return sub + + +def test_complimentary_is_active_but_not_paid(): + sub = make_sub(plan="pro", status="complimentary") + assert sub.is_active is True + assert sub.is_paid is False + assert sub.has_pro_entitlement is True + + +def test_paid_pro_active(): + sub = make_sub(plan="pro", status="active") + assert sub.is_paid is True + assert sub.has_pro_entitlement is True + + +def test_trial_unexpired_has_entitlement(): + sub = make_sub(plan="pro", status="trialing", current_period_end=datetime.now(timezone.utc) + timedelta(days=5)) + assert sub.is_active is True + assert sub.is_paid is False + assert sub.has_pro_entitlement is True + + +def test_trial_expired_no_entitlement(): + sub = make_sub(plan="pro", status="trialing", current_period_end=datetime.now(timezone.utc) - timedelta(hours=1)) + assert sub.has_pro_entitlement is False + + +def test_canceled_no_entitlement(): + sub = make_sub(plan="pro", status="canceled") + assert sub.is_active is False + assert sub.has_pro_entitlement is False +``` + +- [ ] **Step 3: Run tests** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_subscription_properties.py -v --override-ini="addopts=" +``` + +Expected: all pass. + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/models/subscription.py backend/tests/test_subscription_properties.py +git commit -m "feat(billing): add complimentary status, fix is_paid, add has_pro_entitlement" +``` + +--- + +### Task 10: Remove the trial auto-downgrade in deps.py + +**Files:** +- Modify: `backend/app/api/deps.py:81-129` +- Test: `backend/tests/test_get_current_active_user_no_mutation.py` + +- [ ] **Step 1: Remove the auto-downgrade block** + +In `backend/app/api/deps.py`, find lines 109-127 (the `Lightweight trial expiry check` block) and **delete the entire if-block**, leaving only the early return paths and Sentry user context: + +```python +async def get_current_active_user( + request: Request, + current_user: Annotated[User, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> User: + """Ensure user is active (not disabled). Enforces must_change_password — + blocks all routes except allowlist. + + Trial expiry enforcement now happens via require_active_subscription in + individual routers, NOT here. This dep no longer mutates Subscription + state. + """ + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account has been deactivated" + ) + + if current_user.must_change_password: + if request.url.path not in _PASSWORD_CHANGE_ALLOWLIST: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="password_change_required" + ) + + sentry_sdk.set_user({"id": str(current_user.id), "email": current_user.email}) + + return current_user +``` + +Remove the now-unused imports `from app.models.subscription import Subscription` and `from datetime import datetime, timezone` if they were inside the function. + +- [ ] **Step 2: Test that trialing+expired is NOT mutated** + +```python +# backend/tests/test_get_current_active_user_no_mutation.py +import pytest +from datetime import datetime, timezone, timedelta +from sqlalchemy import select +from app.models.subscription import Subscription + + +@pytest.mark.asyncio +async def test_expired_trial_is_not_mutated_by_get_current_active_user( + db_session, authed_client, test_account, test_user +): + """The previous deps.py:109 logic mutated trialing→active+free on expiry. + That's gone. An expired-trial Subscription should retain status='trialing' + and current_period_end after any authenticated request.""" + sub = Subscription( + account_id=test_account.id, + plan="pro", + status="trialing", + current_period_end=datetime.now(timezone.utc) - timedelta(hours=1), + ) + db_session.add(sub) + await db_session.commit() + + # Call any authenticated endpoint that goes through get_current_active_user. + response = await authed_client.get("/api/v1/auth/me") + assert response.status_code == 200 + + await db_session.refresh(sub) + assert sub.status == "trialing" + assert sub.plan == "pro" + assert sub.current_period_end is not None +``` + +- [ ] **Step 3: Run test** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_get_current_active_user_no_mutation.py -v --override-ini="addopts=" +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/api/deps.py backend/tests/test_get_current_active_user_no_mutation.py +git commit -m "refactor(deps): remove trial auto-downgrade; expiry now non-mutating per spec" +``` + +--- + +### Task 11: Add require_active_subscription dep + +**Files:** +- Modify: `backend/app/api/deps.py` +- Test: `backend/tests/test_subscription_guards.py` + +- [ ] **Step 1: Add the dep** + +Append to `backend/app/api/deps.py`: + +```python +from datetime import datetime, timezone + +_SUBSCRIPTION_GUARD_ALLOWLIST = { + "/api/v1/auth/me", + "/api/v1/auth/logout", + "/api/v1/auth/password/change", + "/api/v1/auth/email/send-verification", + "/api/v1/auth/email/verify", + "/api/v1/billing/state", + "/api/v1/billing/checkout-session", + "/api/v1/billing/portal-session", + "/api/v1/users/me", + "/api/v1/users/me/onboarding-step", +} + + +async def require_active_subscription( + request: Request, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_admin_db)], +): + """Returns the Subscription row when the account has access; raises 402 + when locked. Mounted on routers requiring Pro entitlement. + + 'Locked' = (trialing AND current_period_end < now()) OR + (canceled OR incomplete OR no subscription). + Active states: active, complimentary, trialing-with-time-remaining, past_due. + """ + if request.url.path in _SUBSCRIPTION_GUARD_ALLOWLIST: + return None + + from app.models.subscription import Subscription + result = await db.execute( + select(Subscription).where(Subscription.account_id == current_user.account_id) + ) + sub = result.scalar_one_or_none() + + if sub is None: + raise HTTPException( + status_code=402, + detail={"error": "no_subscription", "upgrade_url": "/account/billing/select-plan"}, + ) + + now = datetime.now(timezone.utc) + is_live = ( + sub.status in ("active", "complimentary", "past_due") + or ( + sub.status == "trialing" + and sub.current_period_end is not None + and sub.current_period_end > now + ) + ) + if not is_live: + raise HTTPException( + status_code=402, + detail={ + "error": "subscription_inactive", + "status": sub.status, + "plan": sub.plan, + "current_period_end": sub.current_period_end.isoformat() if sub.current_period_end else None, + "upgrade_url": "/account/billing/select-plan", + }, + ) + + return sub +``` + +- [ ] **Step 2: Tests** + +```python +# backend/tests/test_subscription_guards.py +import pytest +from datetime import datetime, timezone, timedelta +from sqlalchemy import select +from app.models.subscription import Subscription + + +@pytest.mark.asyncio +async def test_active_subscription_passes(authed_client, db_session, test_account): + db_session.add(Subscription(account_id=test_account.id, plan="pro", status="active")) + await db_session.commit() + response = await authed_client.get("/api/v1/trees/") # any protected route + assert response.status_code != 402 + + +@pytest.mark.asyncio +async def test_complimentary_subscription_passes(authed_client, db_session, test_account): + db_session.add(Subscription(account_id=test_account.id, plan="pro", status="complimentary")) + await db_session.commit() + response = await authed_client.get("/api/v1/trees/") + assert response.status_code != 402 + + +@pytest.mark.asyncio +async def test_trialing_unexpired_passes(authed_client, db_session, test_account): + db_session.add(Subscription( + account_id=test_account.id, + plan="pro", + status="trialing", + current_period_end=datetime.now(timezone.utc) + timedelta(days=5), + )) + await db_session.commit() + response = await authed_client.get("/api/v1/trees/") + assert response.status_code != 402 + + +@pytest.mark.asyncio +async def test_trialing_expired_returns_402(authed_client, db_session, test_account): + db_session.add(Subscription( + account_id=test_account.id, + plan="pro", + status="trialing", + current_period_end=datetime.now(timezone.utc) - timedelta(hours=1), + )) + await db_session.commit() + response = await authed_client.get("/api/v1/trees/") + assert response.status_code == 402 + body = response.json() + assert body["detail"]["error"] == "subscription_inactive" + + +@pytest.mark.asyncio +async def test_canceled_returns_402(authed_client, db_session, test_account): + db_session.add(Subscription(account_id=test_account.id, plan="pro", status="canceled")) + await db_session.commit() + response = await authed_client.get("/api/v1/trees/") + assert response.status_code == 402 + + +@pytest.mark.asyncio +async def test_billing_state_endpoint_bypasses_guard(authed_client, db_session, test_account): + """Allowlisted route works even when subscription is canceled.""" + db_session.add(Subscription(account_id=test_account.id, plan="pro", status="canceled")) + await db_session.commit() + # /billing/state will be added in Task 24; this test currently asserts the + # allowlist by hitting /auth/me (also allowlisted). + response = await authed_client.get("/api/v1/auth/me") + assert response.status_code == 200 +``` + +- [ ] **Step 3: Mount the guard on existing protected routers** + +This requires editing `backend/app/api/router.py` to add the dep to the routers that gate Pro functionality. Apply with care — the implementation plan within this task is targeted: add `dependencies=[Depends(require_active_subscription)]` to: + +- `trees_router` (in `router.py`) +- `sessions_router` +- `flowpilot_router` (FlowPilot endpoints) +- `scripts_router` +- `psa_integrations_router` +- `analytics_router` +- `assistant_chat_router` +- `step_library_router` +- `template_trees_router` + +**Do NOT** add to: `auth_router`, `users_router`, `accounts_router` (selectively; some endpoints may need it), `admin_router` family, `webhooks_router`, `public_router`. The allowlist in the guard handles per-path bypass for routes within mounted routers. + +```python +# backend/app/api/router.py — example pattern +from app.api.deps import require_active_subscription + +api_router.include_router( + trees.router, + dependencies=[Depends(require_active_subscription)], + tags=["trees"], +) +``` + +- [ ] **Step 4: Run tests** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_subscription_guards.py -v --override-ini="addopts=" +``` + +Expected: all pass. Existing test suite should also still pass — verify with: + +```bash +docker exec resolutionflow_backend pytest --override-ini="addopts=" +``` + +If existing tests break because they don't seed a Subscription, update fixtures to provide an `active` Subscription by default for `test_account`. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/deps.py backend/app/api/router.py backend/tests/test_subscription_guards.py backend/tests/conftest.py +git commit -m "feat(deps): add require_active_subscription guard with allowlist" +``` + +--- + +### Task 12: Add require_verified_email_after_grace dep + +**Files:** +- Modify: `backend/app/api/deps.py` +- Modify: `backend/app/api/router.py` +- Test: `backend/tests/test_email_verification_guard.py` + +- [ ] **Step 1: Add the dep** + +Append to `backend/app/api/deps.py`: + +```python +from datetime import timedelta + +_EMAIL_VERIFICATION_ALLOWLIST = { + "/api/v1/auth/me", + "/api/v1/auth/logout", + "/api/v1/auth/email/send-verification", + "/api/v1/auth/email/verify", + "/api/v1/auth/password/change", + "/api/v1/users/me", + "/api/v1/billing/state", + "/api/v1/billing/checkout-session", + "/api/v1/billing/portal-session", +} + +VERIFICATION_GRACE_DAYS = 7 + + +async def require_verified_email_after_grace( + request: Request, + current_user: Annotated[User, Depends(get_current_active_user)], +): + """Enforces 'this user has verified email OR is still in 7-day grace.' + OAuth signups bypass cleanly because /auth/{google,microsoft}/callback + sets users.email_verified_at = now() (provider-attested).""" + if request.url.path in _EMAIL_VERIFICATION_ALLOWLIST: + return + + if current_user.email_verified_at is not None: + return + + grace_ends = current_user.created_at + timedelta(days=VERIFICATION_GRACE_DAYS) + if datetime.now(timezone.utc) < grace_ends: + return + + raise HTTPException( + status_code=403, + detail={ + "error": "email_not_verified", + "grace_ended_at": grace_ends.isoformat(), + "resend_url": "/api/v1/auth/email/send-verification", + }, + ) +``` + +- [ ] **Step 2: Tests** + +```python +# backend/tests/test_email_verification_guard.py +import pytest +from datetime import datetime, timezone, timedelta + + +@pytest.mark.asyncio +async def test_verified_user_passes(authed_client, db_session, test_user): + test_user.email_verified_at = datetime.now(timezone.utc) + await db_session.commit() + response = await authed_client.get("/api/v1/trees/") + assert response.status_code != 403 + + +@pytest.mark.asyncio +async def test_unverified_in_grace_passes(authed_client, db_session, test_user): + test_user.email_verified_at = None + test_user.created_at = datetime.now(timezone.utc) - timedelta(days=2) + await db_session.commit() + response = await authed_client.get("/api/v1/trees/") + assert response.status_code != 403 + + +@pytest.mark.asyncio +async def test_unverified_past_grace_blocks(authed_client, db_session, test_user): + test_user.email_verified_at = None + test_user.created_at = datetime.now(timezone.utc) - timedelta(days=10) + await db_session.commit() + response = await authed_client.get("/api/v1/trees/") + assert response.status_code == 403 + body = response.json() + assert body["detail"]["error"] == "email_not_verified" + + +@pytest.mark.asyncio +async def test_unverified_past_grace_allowlisted_still_passes(authed_client, db_session, test_user): + test_user.email_verified_at = None + test_user.created_at = datetime.now(timezone.utc) - timedelta(days=10) + await db_session.commit() + response = await authed_client.get("/api/v1/auth/me") + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_combined_guards_unverified_expired_trial(authed_client, db_session, test_user, test_account): + """A user who is BOTH past grace AND on an expired trial should get blocked + by one of the two guards. Either error is acceptable; we just verify a + refusal.""" + from app.models.subscription import Subscription + test_user.email_verified_at = None + test_user.created_at = datetime.now(timezone.utc) - timedelta(days=10) + db_session.add(Subscription( + account_id=test_account.id, plan="pro", status="trialing", + current_period_end=datetime.now(timezone.utc) - timedelta(hours=1), + )) + await db_session.commit() + response = await authed_client.get("/api/v1/trees/") + assert response.status_code in (402, 403) +``` + +- [ ] **Step 3: Mount on the same routers** + +In `backend/app/api/router.py`, add `Depends(require_verified_email_after_grace)` alongside `Depends(require_active_subscription)` on the routers listed in Task 11 Step 3. + +- [ ] **Step 4: Run tests** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_email_verification_guard.py -v --override-ini="addopts=" +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/deps.py backend/app/api/router.py backend/tests/test_email_verification_guard.py +git commit -m "feat(deps): add require_verified_email_after_grace guard" +``` + +--- + +## Phase C — BillingService + Stripe webhook handler + +### Task 13: BillingService skeleton + start_trial; integrate into /auth/register + +**Files:** +- Create: `backend/app/services/billing.py` +- Modify: `backend/app/api/endpoints/auth.py` (register handler) +- Test: `backend/tests/test_billing_service.py` + +- [ ] **Step 1: BillingService skeleton** + +```python +# backend/app/services/billing.py +"""Single billing service module. Stripe is the only impl — no provider +abstraction. Account row is canonical local state; Stripe is canonical +remote state; the webhook handler bridges the two.""" +from datetime import datetime, timezone, timedelta +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.account import Account +from app.models.subscription import Subscription + + +TRIAL_DAYS = 14 + + +class BillingService: + @staticmethod + async def start_trial(db: AsyncSession, account_id) -> Subscription: + """Idempotent. Creates a trialing Subscription on Pro for the account if + one doesn't exist; otherwise returns the existing row.""" + result = await db.execute( + select(Subscription).where(Subscription.account_id == account_id) + ) + existing = result.scalar_one_or_none() + if existing is not None: + return existing + + sub = Subscription( + account_id=account_id, + plan="pro", + status="trialing", + current_period_start=datetime.now(timezone.utc), + current_period_end=datetime.now(timezone.utc) + timedelta(days=TRIAL_DAYS), + ) + db.add(sub) + await db.commit() + await db.refresh(sub) + return sub +``` + +- [ ] **Step 2: Wire into /auth/register** + +In `backend/app/api/endpoints/auth.py` (the registration handler — search for the existing `@router.post("/register")` block), call `BillingService.start_trial(db, account.id)` after the Account is created. Do **NOT** call it for invitee signups (when `account_invite_code` was provided and matched an existing account) — those users join an existing account that already has a subscription. + +```python +# inside the register handler, after Account creation: +from app.services.billing import BillingService + +# ... existing user/account creation ... + +if not joining_existing_account: + # New shop — start a Pro trial + await BillingService.start_trial(db, account.id) +``` + +- [ ] **Step 3: Test** + +```python +# backend/tests/test_billing_service.py +import pytest +from datetime import datetime, timezone +from sqlalchemy import select +from app.models.subscription import Subscription +from app.services.billing import BillingService + + +@pytest.mark.asyncio +async def test_start_trial_creates_trialing_pro_subscription(db_session, test_account): + sub = await BillingService.start_trial(db_session, test_account.id) + assert sub.plan == "pro" + assert sub.status == "trialing" + assert sub.current_period_end is not None + assert sub.current_period_end > datetime.now(timezone.utc) + + +@pytest.mark.asyncio +async def test_start_trial_is_idempotent(db_session, test_account): + sub1 = await BillingService.start_trial(db_session, test_account.id) + sub2 = await BillingService.start_trial(db_session, test_account.id) + assert sub1.id == sub2.id + + rows = (await db_session.execute( + select(Subscription).where(Subscription.account_id == test_account.id) + )).scalars().all() + assert len(rows) == 1 + + +@pytest.mark.asyncio +async def test_register_creates_trial_subscription(client, db_session): + response = await client.post("/api/v1/auth/register", json={ + "email": "newshop@example.com", + "password": "Verystrong1Pwd", + "name": "New Shop", + }) + assert response.status_code in (200, 201) + + from app.models.user import User + user = (await db_session.execute(select(User).where(User.email == "newshop@example.com"))).scalar_one() + sub = (await db_session.execute(select(Subscription).where(Subscription.account_id == user.account_id))).scalar_one() + assert sub.plan == "pro" + assert sub.status == "trialing" +``` + +- [ ] **Step 4: Run tests** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_billing_service.py -v --override-ini="addopts=" +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/billing.py backend/app/api/endpoints/auth.py backend/tests/test_billing_service.py +git commit -m "feat(billing): add BillingService.start_trial; wire into /auth/register" +``` + +--- + +### Task 14: BillingService.create_checkout_session + endpoint + +**Files:** +- Modify: `backend/app/services/billing.py` +- Create: `backend/app/api/endpoints/billing.py` +- Create: `backend/app/schemas/billing.py` +- Modify: `backend/app/api/router.py` +- Modify: `backend/app/core/config.py` (Stripe settings) +- Test: `backend/tests/test_billing_checkout.py` + +- [ ] **Step 1: Schemas** + +```python +# backend/app/schemas/billing.py +from typing import Optional, Literal +from datetime import datetime +from pydantic import BaseModel + + +class CheckoutSessionCreate(BaseModel): + plan: Literal["pro", "starter", "team", "enterprise"] + seats: int + billing_interval: Literal["monthly", "annual"] = "monthly" + + +class CheckoutSessionResponse(BaseModel): + url: str +``` + +- [ ] **Step 2: Add config keys** + +In `backend/app/core/config.py`, add to `Settings`: + +```python + STRIPE_SECRET_KEY: Optional[str] = None + STRIPE_PUBLISHABLE_KEY: Optional[str] = None + STRIPE_WEBHOOK_SECRET: Optional[str] = None + SELF_SERVE_ENABLED: bool = False + + @property + def stripe_enabled(self) -> bool: + return bool(self.STRIPE_SECRET_KEY) +``` + +- [ ] **Step 3: Service method** + +Add to `backend/app/services/billing.py`: + +```python +import stripe +from app.core.config import settings +from app.models.plan_billing import PlanBilling + + +class BillingService: + # ... existing start_trial ... + + @staticmethod + async def create_checkout_session( + db: AsyncSession, + account: Account, + plan: str, + seats: int, + billing_interval: str, + success_url: str, + cancel_url: str, + ) -> str: + if not settings.stripe_enabled: + raise RuntimeError("Stripe not configured") + stripe.api_key = settings.STRIPE_SECRET_KEY + + # Look up Stripe price id for the requested plan + interval + plan_billing = (await db.execute( + select(PlanBilling).where(PlanBilling.plan == plan) + )).scalar_one_or_none() + if plan_billing is None: + raise ValueError(f"Unknown plan: {plan}") + price_id = ( + plan_billing.stripe_monthly_price_id if billing_interval == "monthly" + else plan_billing.stripe_annual_price_id + ) + if price_id is None: + raise RuntimeError(f"Plan '{plan}' has no Stripe price for {billing_interval}") + + # Ensure Stripe Customer exists on the Account row + if account.stripe_customer_id is None: + customer = stripe.Customer.create( + email=None, # email comes from session.customer_email below + metadata={"account_id": str(account.id)}, + ) + account.stripe_customer_id = customer.id + await db.commit() + + # Read the live subscription's current_period_end to pass as trial_end if still trialing + sub = (await db.execute( + select(Subscription).where(Subscription.account_id == account.id) + )).scalar_one_or_none() + subscription_data = {} + if sub and sub.status == "trialing" and sub.current_period_end and sub.current_period_end > datetime.now(timezone.utc): + subscription_data["trial_end"] = int(sub.current_period_end.timestamp()) + + session = stripe.checkout.Session.create( + customer=account.stripe_customer_id, + line_items=[{"price": price_id, "quantity": seats}], + mode="subscription", + subscription_data=subscription_data or None, + success_url=success_url, + cancel_url=cancel_url, + allow_promotion_codes=False, # promo codes deferred to v2 per spec + ) + return session.url +``` + +- [ ] **Step 4: Endpoint** + +```python +# backend/app/api/endpoints/billing.py +from typing import Annotated +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user, require_admin_db +from app.core.database import get_admin_db +from app.core.config import settings +from app.models.user import User +from app.schemas.billing import CheckoutSessionCreate, CheckoutSessionResponse +from app.services.billing import BillingService + +router = APIRouter(prefix="/billing", tags=["billing"]) + + +@router.post("/checkout-session", response_model=CheckoutSessionResponse) +async def create_checkout_session( + payload: CheckoutSessionCreate, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> CheckoutSessionResponse: + url = await BillingService.create_checkout_session( + db=db, + account=current_user.account, + plan=payload.plan, + seats=payload.seats, + billing_interval=payload.billing_interval, + success_url=f"{settings.FRONTEND_URL}/account/billing?success=1", + cancel_url=f"{settings.FRONTEND_URL}/account/billing/select-plan", + ) + return CheckoutSessionResponse(url=url) +``` + +Register in `backend/app/api/router.py`: + +```python +from app.api.endpoints import billing +api_router.include_router(billing.router) +``` + +- [ ] **Step 5: Test with respx-mocked Stripe** + +```python +# backend/tests/test_billing_checkout.py +import pytest +import respx +from httpx import Response +from sqlalchemy import select +from app.models.plan_billing import PlanBilling + + +@pytest.mark.asyncio +@respx.mock +async def test_checkout_session_creates_stripe_session(authed_client, db_session, test_account): + db_session.add(PlanBilling( + plan="pro", + display_name="Pro", + stripe_product_id="prod_test", + stripe_monthly_price_id="price_test_monthly", + )) + await db_session.commit() + + respx.post("https://api.stripe.com/v1/customers").mock( + return_value=Response(200, json={"id": "cus_test_123"}) + ) + respx.post("https://api.stripe.com/v1/checkout/sessions").mock( + return_value=Response(200, json={"id": "cs_test", "url": "https://checkout.stripe.com/test"}) + ) + + response = await authed_client.post("/api/v1/billing/checkout-session", json={ + "plan": "pro", + "seats": 3, + "billing_interval": "monthly", + }) + assert response.status_code == 200 + body = response.json() + assert body["url"] == "https://checkout.stripe.com/test" + + await db_session.refresh(test_account) + assert test_account.stripe_customer_id == "cus_test_123" +``` + +- [ ] **Step 6: Run + commit** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_billing_checkout.py -v --override-ini="addopts=" +git add backend/app/services/billing.py backend/app/api/endpoints/billing.py backend/app/schemas/billing.py backend/app/api/router.py backend/app/core/config.py backend/tests/test_billing_checkout.py +git commit -m "feat(billing): add /billing/checkout-session via BillingService" +``` + +--- + +### Task 15: BillingService.apply_subscription_event + idempotency + +**Files:** +- Modify: `backend/app/services/billing.py` +- Test: `backend/tests/test_billing_service.py` + +- [ ] **Step 1: Add the event-application method** + +Append to `backend/app/services/billing.py`: + +```python +from datetime import datetime, timezone +from app.models.stripe_event import StripeEvent +from sqlalchemy.exc import IntegrityError + + +class BillingService: + # ... existing methods ... + + @staticmethod + async def apply_subscription_event( + db: AsyncSession, event_id: str, event_type: str, payload: dict + ) -> bool: + """Idempotent. Returns True if the event was applied; False if it had + already been processed (idempotent ack). The webhook handler returns 200 + either way.""" + # Idempotency check — atomic insert + try: + db.add(StripeEvent( + id=event_id, + event_type=event_type, + payload_excerpt=_excerpt(payload), + )) + await db.commit() + except IntegrityError: + await db.rollback() + return False + + # Dispatch + if event_type == "checkout.session.completed": + await _handle_checkout_completed(db, payload) + elif event_type == "customer.subscription.updated": + await _handle_subscription_updated(db, payload) + elif event_type == "customer.subscription.deleted": + await _handle_subscription_deleted(db, payload) + elif event_type == "invoice.payment_failed": + await _handle_payment_failed(db, payload) + elif event_type == "invoice.payment_succeeded": + await _handle_payment_succeeded(db, payload) + # other events: just record + ack + return True + + +def _excerpt(payload: dict) -> dict: + obj = payload.get("data", {}).get("object", {}) + return { + "object_id": obj.get("id"), + "customer": obj.get("customer"), + "subscription": obj.get("subscription"), + "status": obj.get("status"), + } + + +async def _handle_checkout_completed(db: AsyncSession, payload: dict): + obj = payload["data"]["object"] + customer_id = obj["customer"] + subscription_id = obj["subscription"] + + account = (await db.execute( + select(Account).where(Account.stripe_customer_id == customer_id) + )).scalar_one_or_none() + if account is None: + return # webhook for unknown customer — log and ack + + sub = (await db.execute( + select(Subscription).where(Subscription.account_id == account.id) + )).scalar_one_or_none() + if sub is None: + return + + # Fetch the live Stripe subscription for canonical period + price details + stripe.api_key = settings.STRIPE_SECRET_KEY + stripe_sub = stripe.Subscription.retrieve(subscription_id) + sub.stripe_subscription_id = subscription_id + sub.stripe_price_id = stripe_sub["items"]["data"][0]["price"]["id"] + sub.status = "active" + sub.current_period_start = datetime.fromtimestamp(stripe_sub["current_period_start"], tz=timezone.utc) + sub.current_period_end = datetime.fromtimestamp(stripe_sub["current_period_end"], tz=timezone.utc) + sub.seat_limit = stripe_sub["items"]["data"][0]["quantity"] + # Map price_id → plan via plan_billing + pb = (await db.execute( + select(PlanBilling).where( + (PlanBilling.stripe_monthly_price_id == sub.stripe_price_id) | + (PlanBilling.stripe_annual_price_id == sub.stripe_price_id) + ) + )).scalar_one_or_none() + if pb is not None: + sub.plan = pb.plan + await db.commit() + + +async def _handle_subscription_updated(db: AsyncSession, payload: dict): + obj = payload["data"]["object"] + sub = (await db.execute( + select(Subscription).where(Subscription.stripe_subscription_id == obj["id"]) + )).scalar_one_or_none() + if sub is None: + return + sub.status = obj["status"] + sub.current_period_start = datetime.fromtimestamp(obj["current_period_start"], tz=timezone.utc) + sub.current_period_end = datetime.fromtimestamp(obj["current_period_end"], tz=timezone.utc) + sub.cancel_at_period_end = obj.get("cancel_at_period_end", False) + sub.seat_limit = obj["items"]["data"][0]["quantity"] + await db.commit() + + +async def _handle_subscription_deleted(db: AsyncSession, payload: dict): + obj = payload["data"]["object"] + sub = (await db.execute( + select(Subscription).where(Subscription.stripe_subscription_id == obj["id"]) + )).scalar_one_or_none() + if sub is None: + return + sub.status = "canceled" + await db.commit() + + +async def _handle_payment_failed(db: AsyncSession, payload: dict): + obj = payload["data"]["object"] + subscription_id = obj.get("subscription") + if not subscription_id: + return + sub = (await db.execute( + select(Subscription).where(Subscription.stripe_subscription_id == subscription_id) + )).scalar_one_or_none() + if sub is None: + return + sub.status = "past_due" + await db.commit() + + +async def _handle_payment_succeeded(db: AsyncSession, payload: dict): + obj = payload["data"]["object"] + subscription_id = obj.get("subscription") + if not subscription_id: + return + sub = (await db.execute( + select(Subscription).where(Subscription.stripe_subscription_id == subscription_id) + )).scalar_one_or_none() + if sub is None: + return + if sub.status == "past_due": + sub.status = "active" + await db.commit() +``` + +- [ ] **Step 2: Idempotency test** + +```python +# Add to backend/tests/test_billing_service.py + +@pytest.mark.asyncio +async def test_apply_subscription_event_is_idempotent(db_session, test_account): + payload = { + "data": {"object": { + "id": "evt_test_1", + "customer": "cus_xxx", + "subscription": "sub_xxx", + "status": "active", + }} + } + + applied_first = await BillingService.apply_subscription_event( + db_session, "evt_test_1", "customer.subscription.updated", payload + ) + applied_second = await BillingService.apply_subscription_event( + db_session, "evt_test_1", "customer.subscription.updated", payload + ) + assert applied_first is True + assert applied_second is False # already-processed → ack without re-applying +``` + +- [ ] **Step 3: Run + commit** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_billing_service.py -v --override-ini="addopts=" +git add backend/app/services/billing.py backend/tests/test_billing_service.py +git commit -m "feat(billing): apply_subscription_event with stripe_events idempotency" +``` + +--- + +### Task 16: Extend the Stripe webhook handler stub + +**Files:** +- Modify: `backend/app/api/endpoints/webhooks.py` +- Test: `backend/tests/test_stripe_webhook_handler.py` + +- [ ] **Step 1: Wire BillingService into webhook handler** + +Replace the stub body in `backend/app/api/endpoints/webhooks.py`: + +```python +import logging +from fastapi import APIRouter, Request, HTTPException, status, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.admin_database import get_admin_db +from app.core.config import settings +from app.services.billing import BillingService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/webhooks", tags=["webhooks"]) + + +@router.post("/stripe") +async def stripe_webhook( + request: Request, + db: AsyncSession = Depends(get_admin_db), +): + """Stripe webhook handler. Public endpoint; signature verification is the + only gate. Idempotency via stripe_events table.""" + if not settings.stripe_enabled: + return {"status": "ok", "message": "Stripe not configured, event ignored"} + + payload = await request.body() + sig_header = request.headers.get("stripe-signature") + if not sig_header: + raise HTTPException(status_code=400, detail="Missing stripe-signature header") + + try: + import stripe + stripe.api_key = settings.STRIPE_SECRET_KEY + event = stripe.Webhook.construct_event( + payload, sig_header, settings.STRIPE_WEBHOOK_SECRET + ) + except (ValueError, stripe.error.SignatureVerificationError) as e: + logger.warning("stripe webhook bad signature: %s", e) + raise HTTPException(status_code=400, detail="Invalid signature") + + applied = await BillingService.apply_subscription_event( + db, + event_id=event["id"], + event_type=event["type"], + payload={"data": event["data"]}, + ) + return {"status": "ok", "applied": applied} +``` + +- [ ] **Step 2: Webhook integration tests** + +```python +# backend/tests/test_stripe_webhook_handler.py +import pytest +import json +from sqlalchemy import select +from unittest.mock import patch +from app.models.subscription import Subscription +from app.models.account import Account + + +def _make_event(event_id, event_type, obj): + return { + "id": event_id, + "type": event_type, + "data": {"object": obj}, + } + + +@pytest.mark.asyncio +async def test_checkout_completed_activates_subscription(client, db_session, test_account): + test_account.stripe_customer_id = "cus_xxx" + sub = Subscription(account_id=test_account.id, plan="pro", status="trialing") + db_session.add(sub) + await db_session.commit() + + event = _make_event("evt_co_1", "checkout.session.completed", { + "id": "cs_xxx", + "customer": "cus_xxx", + "subscription": "sub_xxx", + }) + + with patch("stripe.Subscription.retrieve", return_value={ + "id": "sub_xxx", + "status": "active", + "current_period_start": 1714521600, + "current_period_end": 1717113600, + "items": {"data": [{ + "price": {"id": "price_test_monthly"}, + "quantity": 5, + }]}, + "cancel_at_period_end": False, + }), patch("stripe.Webhook.construct_event", return_value=event): + response = await client.post( + "/api/v1/webhooks/stripe", + content=json.dumps(event), + headers={"stripe-signature": "fake-sig"}, + ) + assert response.status_code == 200 + + await db_session.refresh(sub) + assert sub.status == "active" + assert sub.stripe_subscription_id == "sub_xxx" + + +@pytest.mark.asyncio +async def test_subscription_deleted_cancels_account(client, db_session, test_account): + sub = Subscription( + account_id=test_account.id, plan="pro", status="active", + stripe_subscription_id="sub_xxx", + ) + db_session.add(sub) + await db_session.commit() + + event = _make_event("evt_del_1", "customer.subscription.deleted", { + "id": "sub_xxx", + "current_period_start": 1714521600, + "current_period_end": 1717113600, + "items": {"data": [{"quantity": 1}]}, + }) + + with patch("stripe.Webhook.construct_event", return_value=event): + response = await client.post( + "/api/v1/webhooks/stripe", + content=json.dumps(event), + headers={"stripe-signature": "fake-sig"}, + ) + assert response.status_code == 200 + + await db_session.refresh(sub) + assert sub.status == "canceled" + + +@pytest.mark.asyncio +async def test_webhook_signature_failure_returns_400(client): + with patch("stripe.Webhook.construct_event", side_effect=ValueError("bad sig")): + response = await client.post( + "/api/v1/webhooks/stripe", + content=b"{}", + headers={"stripe-signature": "fake-sig"}, + ) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_webhook_idempotency(client, db_session, test_account): + test_account.stripe_customer_id = "cus_xxx" + db_session.add(Subscription(account_id=test_account.id, plan="pro", status="trialing")) + await db_session.commit() + + event = _make_event("evt_dup_1", "customer.subscription.updated", { + "id": "sub_yyy", + "status": "active", + "current_period_start": 1714521600, + "current_period_end": 1717113600, + "items": {"data": [{"quantity": 1}]}, + "cancel_at_period_end": False, + }) + with patch("stripe.Webhook.construct_event", return_value=event): + r1 = await client.post("/api/v1/webhooks/stripe", content=json.dumps(event), headers={"stripe-signature": "x"}) + r2 = await client.post("/api/v1/webhooks/stripe", content=json.dumps(event), headers={"stripe-signature": "x"}) + assert r1.status_code == 200 + assert r2.status_code == 200 + assert r1.json()["applied"] is True + assert r2.json()["applied"] is False +``` + +- [ ] **Step 3: Run + commit** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_stripe_webhook_handler.py -v --override-ini="addopts=" +git add backend/app/api/endpoints/webhooks.py backend/tests/test_stripe_webhook_handler.py +git commit -m "feat(billing): extend Stripe webhook stub with concrete event handlers" +``` + +--- + +## Phase D — OAuth additions + +### Task 17: OAuth provider helpers + Google callback endpoint + +**Files:** +- Create: `backend/app/services/oauth_providers.py` +- Create: `backend/app/api/endpoints/oauth.py` +- Create: `backend/app/schemas/oauth.py` +- Modify: `backend/app/core/config.py` (Google client id + secret) +- Modify: `backend/app/api/router.py` +- Test: `backend/tests/test_oauth_callbacks.py` + +- [ ] **Step 1: Provider helpers** + +```python +# backend/app/services/oauth_providers.py +"""OAuth provider helpers. Each provider exposes: +- exchange_code(code) -> {provider_subject, email, name} +""" +from dataclasses import dataclass +import httpx +from app.core.config import settings + + +@dataclass +class OAuthProfile: + provider_subject: str + email: str + name: str + + +async def google_exchange_code(code: str, redirect_uri: str) -> OAuthProfile: + async with httpx.AsyncClient(timeout=10) as cli: + token_response = await cli.post( + "https://oauth2.googleapis.com/token", + data={ + "code": code, + "client_id": settings.GOOGLE_CLIENT_ID, + "client_secret": settings.GOOGLE_CLIENT_SECRET, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + }, + ) + token_response.raise_for_status() + access_token = token_response.json()["access_token"] + + userinfo = await cli.get( + "https://openidconnect.googleapis.com/v1/userinfo", + headers={"Authorization": f"Bearer {access_token}"}, + ) + userinfo.raise_for_status() + data = userinfo.json() + return OAuthProfile( + provider_subject=data["sub"], + email=data["email"], + name=data.get("name") or data["email"].split("@")[0], + ) + + +async def microsoft_exchange_code(code: str, redirect_uri: str) -> OAuthProfile: + async with httpx.AsyncClient(timeout=10) as cli: + token_response = await cli.post( + "https://login.microsoftonline.com/common/oauth2/v2.0/token", + data={ + "code": code, + "client_id": settings.MS_CLIENT_ID, + "client_secret": settings.MS_CLIENT_SECRET, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + "scope": "openid email profile", + }, + ) + token_response.raise_for_status() + access_token = token_response.json()["access_token"] + + userinfo = await cli.get( + "https://graph.microsoft.com/v1.0/me", + headers={"Authorization": f"Bearer {access_token}"}, + ) + userinfo.raise_for_status() + data = userinfo.json() + return OAuthProfile( + provider_subject=data["id"], + email=data.get("mail") or data["userPrincipalName"], + name=data.get("displayName") or data["userPrincipalName"].split("@")[0], + ) +``` + +- [ ] **Step 2: Config keys** + +In `backend/app/core/config.py`: + +```python + GOOGLE_CLIENT_ID: Optional[str] = None + GOOGLE_CLIENT_SECRET: Optional[str] = None + MS_CLIENT_ID: Optional[str] = None + MS_CLIENT_SECRET: Optional[str] = None + OAUTH_REDIRECT_BASE: str = "http://localhost:5173" +``` + +- [ ] **Step 3: Google callback endpoint + shared sign-in helper** + +```python +# backend/app/schemas/oauth.py +from pydantic import BaseModel + + +class OAuthCallbackPayload(BaseModel): + code: str + state: str | None = None + + +class OAuthCallbackResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + is_new_user: bool +``` + +```python +# backend/app/api/endpoints/oauth.py +from datetime import datetime, timezone +from typing import Annotated +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.admin_database import get_admin_db +from app.core.config import settings +from app.core.security import create_access_token, create_refresh_token +from app.models.account import Account +from app.models.oauth_identity import OAuthIdentity +from app.models.user import User +from app.schemas.oauth import OAuthCallbackPayload, OAuthCallbackResponse +from app.services.billing import BillingService +from app.services.oauth_providers import ( + google_exchange_code, microsoft_exchange_code, OAuthProfile, +) + +router = APIRouter(prefix="/auth", tags=["auth-oauth"]) + + +async def _sign_in_or_register( + db: AsyncSession, provider: str, profile: OAuthProfile +) -> tuple[User, bool]: + """Returns (user, is_new_user). Idempotent.""" + # Existing identity? + identity = (await db.execute( + select(OAuthIdentity).where( + OAuthIdentity.provider == provider, + OAuthIdentity.provider_subject == profile.provider_subject, + ) + )).scalar_one_or_none() + + if identity: + user = (await db.execute(select(User).where(User.id == identity.user_id))).scalar_one() + return user, False + + # No identity yet — link to existing email if present, else create new account + user = (await db.execute(select(User).where(User.email == profile.email))).scalar_one_or_none() + is_new_user = user is None + if is_new_user: + account = Account(name=profile.name) + db.add(account) + await db.flush() + user = User( + email=profile.email, + name=profile.name, + password_hash=None, # OAuth-only + account_id=account.id, + account_role="owner", + email_verified_at=datetime.now(timezone.utc), + ) + db.add(user) + await db.flush() + await BillingService.start_trial(db, account.id) + + db.add(OAuthIdentity( + user_id=user.id, + provider=provider, + provider_subject=profile.provider_subject, + provider_email_at_link=profile.email, + )) + await db.commit() + await db.refresh(user) + return user, is_new_user + + +@router.post("/google/callback", response_model=OAuthCallbackResponse) +async def google_callback( + payload: OAuthCallbackPayload, + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> OAuthCallbackResponse: + if not settings.GOOGLE_CLIENT_ID: + raise HTTPException(status_code=503, detail="Google sign-in not configured") + redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/google/callback" + profile = await google_exchange_code(payload.code, redirect_uri) + user, is_new = await _sign_in_or_register(db, "google", profile) + return OAuthCallbackResponse( + access_token=create_access_token({"sub": str(user.id)}), + refresh_token=create_refresh_token({"sub": str(user.id), "jti": str(uuid4())}), + is_new_user=is_new, + ) +``` + +- [ ] **Step 4: Register router** + +```python +# backend/app/api/router.py +from app.api.endpoints import oauth as oauth_endpoints +api_router.include_router(oauth_endpoints.router) +``` + +- [ ] **Step 5: Tests** + +```python +# backend/tests/test_oauth_callbacks.py +import pytest +from unittest.mock import patch +from sqlalchemy import select +from app.models.user import User +from app.models.oauth_identity import OAuthIdentity +from app.models.subscription import Subscription +from app.services.oauth_providers import OAuthProfile + + +@pytest.mark.asyncio +async def test_google_callback_creates_user_account_subscription(client, db_session): + profile = OAuthProfile( + provider_subject="google_subject_123", + email="newuser@example.com", + name="New User", + ) + with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile): + response = await client.post("/api/v1/auth/google/callback", json={"code": "auth_code_xyz"}) + assert response.status_code == 200 + body = response.json() + assert body["is_new_user"] is True + assert body["access_token"] + + user = (await db_session.execute(select(User).where(User.email == "newuser@example.com"))).scalar_one() + assert user.password_hash is None + assert user.email_verified_at is not None # provider-attested + + identity = (await db_session.execute( + select(OAuthIdentity).where(OAuthIdentity.user_id == user.id) + )).scalar_one() + assert identity.provider == "google" + assert identity.provider_subject == "google_subject_123" + + sub = (await db_session.execute( + select(Subscription).where(Subscription.account_id == user.account_id) + )).scalar_one() + assert sub.status == "trialing" + assert sub.plan == "pro" + + +@pytest.mark.asyncio +async def test_google_callback_existing_user_is_idempotent(client, db_session, test_user): + profile = OAuthProfile( + provider_subject="google_subject_456", + email=test_user.email, + name=test_user.name, + ) + with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile): + r1 = await client.post("/api/v1/auth/google/callback", json={"code": "x"}) + r2 = await client.post("/api/v1/auth/google/callback", json={"code": "x"}) + assert r1.status_code == 200 + assert r2.status_code == 200 + assert r1.json()["is_new_user"] is False # linked to existing user + assert r2.json()["is_new_user"] is False + + identities = (await db_session.execute( + select(OAuthIdentity).where(OAuthIdentity.user_id == test_user.id) + )).scalars().all() + assert len(identities) == 1 # idempotent — only one row +``` + +- [ ] **Step 6: Run + commit** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_oauth_callbacks.py -v --override-ini="addopts=" +git add backend/app/services/oauth_providers.py backend/app/api/endpoints/oauth.py backend/app/schemas/oauth.py backend/app/api/router.py backend/app/core/config.py backend/tests/test_oauth_callbacks.py +git commit -m "feat(auth): add Google OAuth callback with oauth_identities linking" +``` + +--- + +### Task 18: Microsoft OAuth callback + +**Files:** +- Modify: `backend/app/api/endpoints/oauth.py` +- Test: `backend/tests/test_oauth_callbacks.py` + +- [ ] **Step 1: Add Microsoft endpoint** (mirrors Google) + +```python +# backend/app/api/endpoints/oauth.py — append: +@router.post("/microsoft/callback", response_model=OAuthCallbackResponse) +async def microsoft_callback( + payload: OAuthCallbackPayload, + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> OAuthCallbackResponse: + if not settings.MS_CLIENT_ID: + raise HTTPException(status_code=503, detail="Microsoft sign-in not configured") + redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/microsoft/callback" + profile = await microsoft_exchange_code(payload.code, redirect_uri) + user, is_new = await _sign_in_or_register(db, "microsoft", profile) + return OAuthCallbackResponse( + access_token=create_access_token({"sub": str(user.id)}), + refresh_token=create_refresh_token({"sub": str(user.id), "jti": str(uuid4())}), + is_new_user=is_new, + ) +``` + +- [ ] **Step 2: Test** + +```python +# backend/tests/test_oauth_callbacks.py — append: +@pytest.mark.asyncio +async def test_microsoft_callback_creates_user(client, db_session): + profile = OAuthProfile( + provider_subject="ms_subject_789", + email="msuser@example.com", + name="MS User", + ) + with patch("app.api.endpoints.oauth.microsoft_exchange_code", return_value=profile): + response = await client.post("/api/v1/auth/microsoft/callback", json={"code": "auth_code"}) + assert response.status_code == 200 + user = (await db_session.execute(select(User).where(User.email == "msuser@example.com"))).scalar_one() + identity = (await db_session.execute( + select(OAuthIdentity).where(OAuthIdentity.user_id == user.id) + )).scalar_one() + assert identity.provider == "microsoft" +``` + +- [ ] **Step 3: Run + commit** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_oauth_callbacks.py -v --override-ini="addopts=" +git add backend/app/api/endpoints/oauth.py backend/tests/test_oauth_callbacks.py +git commit -m "feat(auth): add Microsoft OAuth callback" +``` + +--- + +### Task 19: Handle null password_hash in login + password change/reset + +**Files:** +- Modify: `backend/app/api/endpoints/auth.py` +- Test: `backend/tests/test_oauth_only_user_paths.py` + +- [ ] **Step 1: Guard password endpoints** + +In each of these handlers in `backend/app/api/endpoints/auth.py`, before the existing `verify_password(...)` call, add a check: + +```python +if user.password_hash is None: + raise HTTPException( + status_code=400, + detail={ + "error": "use_oauth_provider", + "providers": [i.provider for i in user.oauth_identities], + }, + ) +``` + +Apply to: +- `POST /auth/login` (form-data flow + JSON flow) +- `POST /auth/password/change` +- `POST /auth/password/reset` initiation — when looking up user by email, if `user.password_hash is None`, return 200 with a generic message (do NOT send a reset email; we don't want to leak that an account exists). + +- [ ] **Step 2: Test** + +```python +# backend/tests/test_oauth_only_user_paths.py +import pytest +from sqlalchemy import select +from app.models.user import User +from app.models.oauth_identity import OAuthIdentity + + +@pytest.mark.asyncio +async def test_login_rejects_oauth_only_user_with_helpful_error(client, db_session, test_account): + user = User( + email="oauth-only@example.com", + name="OAuth Only", + password_hash=None, + account_id=test_account.id, + account_role="owner", + ) + db_session.add(user) + await db_session.flush() + db_session.add(OAuthIdentity( + user_id=user.id, provider="google", + provider_subject="g_xyz", provider_email_at_link=user.email, + )) + await db_session.commit() + + response = await client.post("/api/v1/auth/login", data={ + "username": user.email, "password": "wontwork", + }) + assert response.status_code == 400 + body = response.json() + assert body["detail"]["error"] == "use_oauth_provider" + assert "google" in body["detail"]["providers"] + + +@pytest.mark.asyncio +async def test_password_reset_silent_for_oauth_only_user(client, db_session, test_account): + user = User( + email="oauth-only2@example.com", + name="OAuth Only 2", + password_hash=None, + account_id=test_account.id, + account_role="owner", + ) + db_session.add(user) + await db_session.commit() + + response = await client.post("/api/v1/auth/password/reset/initiate", json={ + "email": user.email, + }) + # Generic 200 — does not leak account state + assert response.status_code == 200 +``` + +- [ ] **Step 3: Run + commit** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_oauth_only_user_paths.py -v --override-ini="addopts=" +git add backend/app/api/endpoints/auth.py backend/tests/test_oauth_only_user_paths.py +git commit -m "feat(auth): guard login/password paths against OAuth-only users" +``` + +--- + +## Phase E — Email verification auto-send + email-match enforcement + +### Task 20: Auto-send verification on register + enforce account_invite_code email match + +**Files:** +- Modify: `backend/app/api/endpoints/auth.py` (register handler) +- Test: `backend/tests/test_email_verification_autosend.py` + +- [ ] **Step 1: Modify the register handler** + +In the existing `@router.post("/register")` handler in `backend/app/api/endpoints/auth.py`: + +1. After the User is created, immediately call `EmailService.send_email_verification_email` with a fresh token from `create_email_verification_token`. Do NOT require user click — fires automatically. Existing logic guards `email_verification_enabled` settings flag. + +2. **Email-match enforcement:** if `user_data.account_invite_code` is provided, look up the matching `account_invites` row by `code`. If found, require `user_data.email == invite.email` (case-insensitive). Mismatch → 400 with `{"error": "invite_email_mismatch"}`. Apply this check BEFORE any User is created. + +```python +# backend/app/api/endpoints/auth.py — within register handler + +if user_data.account_invite_code: + invite = (await db.execute( + select(AccountInvite).where(AccountInvite.code == user_data.account_invite_code) + )).scalar_one_or_none() + if not invite or not invite.is_valid: + raise HTTPException(status_code=400, detail={"error": "invite_invalid_or_expired"}) + if invite.email.lower() != user_data.email.lower(): + raise HTTPException(status_code=400, detail={"error": "invite_email_mismatch"}) + +# ... existing user/account creation ... + +# After commit, fire verification email (skip if already verified — e.g., invitee accepting flow) +if user.email_verified_at is None: + raw_token = create_email_verification_token(str(user.id)) + if settings.email_verification_enabled: + try: + verification_url = f"{settings.FRONTEND_URL}/verify-email?token={raw_token}" + await EmailService.send_email_verification_email( + to_email=user.email, verification_url=verification_url, user_name=user.name, + ) + except Exception as e: + logger.warning("verification email send failed for %s: %s", user.email, e) +``` + +- [ ] **Step 2: Test** + +```python +# backend/tests/test_email_verification_autosend.py +import pytest +from unittest.mock import AsyncMock, patch + + +@pytest.mark.asyncio +async def test_register_auto_sends_verification_email(client): + with patch("app.core.email.EmailService.send_email_verification_email", new_callable=AsyncMock) as mock_send: + response = await client.post("/api/v1/auth/register", json={ + "email": "newshop@example.com", + "password": "Verystrong1Pwd", + "name": "New Shop", + }) + assert response.status_code in (200, 201) + mock_send.assert_called_once() + kwargs = mock_send.call_args.kwargs + assert kwargs["to_email"] == "newshop@example.com" + + +@pytest.mark.asyncio +async def test_register_with_account_invite_code_email_mismatch_rejected(client, db_session, test_account, test_user): + from app.models.account_invite import AccountInvite + from datetime import datetime, timezone, timedelta + invite = AccountInvite( + account_id=test_account.id, + invited_by_id=test_user.id, + email="invited@example.com", + code="INVITECODE99", + role="engineer", + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + ) + db_session.add(invite) + await db_session.commit() + + response = await client.post("/api/v1/auth/register", json={ + "email": "wrong-email@example.com", + "password": "Verystrong1Pwd", + "name": "Wrong Email", + "account_invite_code": "INVITECODE99", + }) + assert response.status_code == 400 + assert response.json()["detail"]["error"] == "invite_email_mismatch" +``` + +- [ ] **Step 3: Run + commit** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_email_verification_autosend.py -v --override-ini="addopts=" +git add backend/app/api/endpoints/auth.py backend/tests/test_email_verification_autosend.py +git commit -m "feat(auth): auto-send verification email on register; enforce invite email match" +``` + +--- + +## Phase F — Account invite extensions + +### Task 21: Wire EmailService into POST /accounts/me/invites + +**Files:** +- Modify: `backend/app/api/endpoints/accounts.py:257` +- Test: `backend/tests/test_account_invite_extensions.py` + +- [ ] **Step 1: Update create handler to send email + stamp email_sent_at** + +In `backend/app/api/endpoints/accounts.py` find the existing `create_invite` handler and modify: + +```python +from app.core.email import EmailService +from datetime import datetime, timezone +import logging + +logger = logging.getLogger(__name__) + +@router.post("/me/invites", response_model=AccountInviteResponse, status_code=status.HTTP_201_CREATED) +async def create_invite( + invite_data: AccountInviteCreate, + current_user: Annotated[User, Depends(require_account_owner)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Create an invite to join this account (owner only). Sends email.""" + invite = AccountInvite( + account_id=current_user.account_id, + invited_by_id=current_user.id, + email=invite_data.email, + code=secrets.token_urlsafe(24)[:32], + role=invite_data.role, + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + ) + db.add(invite) + await db.flush() + + # Send invitation email (non-blocking on failure) + try: + accept_url = f"{settings.FRONTEND_URL}/accept-invite?code={invite.code}" + await EmailService.send_account_invite_email( + to_email=invite.email, + inviter_name=current_user.name, + account_name=current_user.account.name, + accept_url=accept_url, + ) + invite.email_sent_at = datetime.now(timezone.utc) + except Exception as e: + logger.warning("invite email send failed for %s: %s", invite.email, e) + + await db.commit() + await db.refresh(invite) + return invite +``` + +- [ ] **Step 2: Test (regression catch)** + +```python +# backend/tests/test_account_invite_extensions.py +import pytest +from unittest.mock import AsyncMock, patch +from sqlalchemy import select +from app.models.account_invite import AccountInvite + + +@pytest.mark.asyncio +async def test_create_invite_sends_email_and_stamps_email_sent_at(authed_owner_client, db_session): + """Regression: today's create_invite does NOT send email. After this task, it MUST.""" + with patch("app.core.email.EmailService.send_account_invite_email", new_callable=AsyncMock) as mock_send: + response = await authed_owner_client.post("/api/v1/accounts/me/invites", json={ + "email": "teammate@example.com", + "role": "engineer", + }) + assert response.status_code == 201 + mock_send.assert_called_once() + + invite = (await db_session.execute( + select(AccountInvite).where(AccountInvite.email == "teammate@example.com") + )).scalar_one() + assert invite.email_sent_at is not None +``` + +- [ ] **Step 3: Run + commit** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_account_invite_extensions.py::test_create_invite_sends_email_and_stamps_email_sent_at -v --override-ini="addopts=" +git add backend/app/api/endpoints/accounts.py backend/tests/test_account_invite_extensions.py +git commit -m "feat(invites): wire EmailService.send_account_invite_email into create handler" +``` + +--- + +### Task 22: Add POST /accounts/me/invites/bulk endpoint + +**Files:** +- Modify: `backend/app/api/endpoints/accounts.py` +- Modify: `backend/app/schemas/account.py` (or wherever invite schemas live) +- Test: `backend/tests/test_account_invite_extensions.py` + +- [ ] **Step 1: Schema** + +In the appropriate schema file (`backend/app/schemas/account.py` likely): + +```python +class AccountInviteBulkCreate(BaseModel): + invites: list[AccountInviteCreate] + + +class AccountInviteBulkResponse(BaseModel): + created: list[AccountInviteResponse] + failed: list[dict] # {email, error} +``` + +- [ ] **Step 2: Endpoint** + +```python +# backend/app/api/endpoints/accounts.py — append: +@router.post("/me/invites/bulk", response_model=AccountInviteBulkResponse, status_code=status.HTTP_201_CREATED) +async def create_invites_bulk( + payload: AccountInviteBulkCreate, + current_user: Annotated[User, Depends(require_account_owner)], + db: Annotated[AsyncSession, Depends(get_db)], +): + created, failed = [], [] + for invite_data in payload.invites: + try: + invite = AccountInvite( + account_id=current_user.account_id, + invited_by_id=current_user.id, + email=invite_data.email, + code=secrets.token_urlsafe(24)[:32], + role=invite_data.role, + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + ) + db.add(invite) + await db.flush() + try: + accept_url = f"{settings.FRONTEND_URL}/accept-invite?code={invite.code}" + await EmailService.send_account_invite_email( + to_email=invite.email, + inviter_name=current_user.name, + account_name=current_user.account.name, + accept_url=accept_url, + ) + invite.email_sent_at = datetime.now(timezone.utc) + except Exception as e: + logger.warning("bulk invite email send failed for %s: %s", invite.email, e) + created.append(invite) + except Exception as e: + failed.append({"email": invite_data.email, "error": str(e)}) + await db.rollback() + await db.commit() + return AccountInviteBulkResponse(created=created, failed=failed) +``` + +- [ ] **Step 3: Test** + +```python +# backend/tests/test_account_invite_extensions.py — append: +@pytest.mark.asyncio +async def test_bulk_invite_creates_n_rows_and_sends_n_emails(authed_owner_client, db_session): + with patch("app.core.email.EmailService.send_account_invite_email", new_callable=AsyncMock) as mock_send: + response = await authed_owner_client.post("/api/v1/accounts/me/invites/bulk", json={ + "invites": [ + {"email": "a@example.com", "role": "engineer"}, + {"email": "b@example.com", "role": "engineer"}, + {"email": "c@example.com", "role": "viewer"}, + ], + }) + assert response.status_code == 201 + body = response.json() + assert len(body["created"]) == 3 + assert len(body["failed"]) == 0 + assert mock_send.call_count == 3 +``` + +- [ ] **Step 4: Run + commit** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_account_invite_extensions.py::test_bulk_invite_creates_n_rows_and_sends_n_emails -v --override-ini="addopts=" +git add backend/app/api/endpoints/accounts.py backend/app/schemas/account.py backend/tests/test_account_invite_extensions.py +git commit -m "feat(invites): add POST /accounts/me/invites/bulk endpoint for wizard step 3" +``` + +--- + +### Task 23: Add DELETE /accounts/me/invites/{id} (soft-revoke) + +**Files:** +- Modify: `backend/app/api/endpoints/accounts.py` +- Test: `backend/tests/test_account_invite_extensions.py` + +- [ ] **Step 1: Endpoint** + +```python +# backend/app/api/endpoints/accounts.py — append: +@router.delete("/me/invites/{invite_id}", status_code=status.HTTP_204_NO_CONTENT) +async def revoke_invite( + invite_id: UUID, + current_user: Annotated[User, Depends(require_account_owner)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Soft-revoke an invitation (sets revoked_at).""" + invite = (await db.execute( + select(AccountInvite).where( + AccountInvite.id == invite_id, + AccountInvite.account_id == current_user.account_id, + ) + )).scalar_one_or_none() + if not invite: + raise HTTPException(status_code=404, detail="Invite not found") + if invite.is_revoked: + return None # idempotent + if invite.is_used: + raise HTTPException(status_code=400, detail="Cannot revoke an accepted invite") + invite.revoked_at = datetime.now(timezone.utc) + await db.commit() + return None +``` + +- [ ] **Step 2: Test** + +```python +@pytest.mark.asyncio +async def test_revoke_invite_sets_revoked_at(authed_owner_client, db_session, test_account, test_user): + invite = AccountInvite( + account_id=test_account.id, + invited_by_id=test_user.id, + email="revoked@example.com", + code="REVOKEME01", + role="engineer", + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + ) + db_session.add(invite) + await db_session.commit() + + response = await authed_owner_client.delete(f"/api/v1/accounts/me/invites/{invite.id}") + assert response.status_code == 204 + + await db_session.refresh(invite) + assert invite.revoked_at is not None + assert invite.is_valid is False + + +@pytest.mark.asyncio +async def test_revoke_invite_idempotent(authed_owner_client, db_session, test_account, test_user): + invite = AccountInvite( + account_id=test_account.id, + invited_by_id=test_user.id, + email="revoked2@example.com", + code="REVOKEME02", + role="engineer", + revoked_at=datetime.now(timezone.utc), + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + ) + db_session.add(invite) + await db_session.commit() + + response = await authed_owner_client.delete(f"/api/v1/accounts/me/invites/{invite.id}") + assert response.status_code == 204 +``` + +- [ ] **Step 3: Run + commit** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_account_invite_extensions.py -v --override-ini="addopts=" +git add backend/app/api/endpoints/accounts.py backend/tests/test_account_invite_extensions.py +git commit -m "feat(invites): add DELETE /accounts/me/invites/{id} soft-revoke route" +``` + +--- + +## Phase G — Billing surface + +### Task 24: GET /billing/state endpoint + +**Files:** +- Modify: `backend/app/services/billing.py` (`get_billing_state` method) +- Modify: `backend/app/api/endpoints/billing.py` (new GET endpoint) +- Modify: `backend/app/schemas/billing.py` +- Test: `backend/tests/test_billing_state_endpoint.py` + +- [ ] **Step 1: Schemas** + +```python +# backend/app/schemas/billing.py — append: +from typing import Optional, Dict +from datetime import datetime +from pydantic import BaseModel + + +class SubscriptionState(BaseModel): + status: str + plan: str + current_period_start: Optional[datetime] + current_period_end: Optional[datetime] + cancel_at_period_end: bool + seat_limit: Optional[int] + has_pro_entitlement: bool + is_paid: bool + + +class PlanBillingState(BaseModel): + display_name: str + description: Optional[str] + monthly_price_cents: Optional[int] + annual_price_cents: Optional[int] + + +class BillingStateResponse(BaseModel): + subscription: SubscriptionState + plan_billing: Optional[PlanBillingState] + plan_limits: Dict[str, object] # full PlanLimits row as dict + enabled_features: Dict[str, bool] # resolved flag map +``` + +- [ ] **Step 2: Service method** + +```python +# backend/app/services/billing.py — append to BillingService: +from app.models.plan_limits import PlanLimits +from app.models.feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride +from app.models.plan_billing import PlanBilling +from sqlalchemy.orm import selectinload + + +class BillingService: + # ... existing methods ... + + @staticmethod + async def get_billing_state(db: AsyncSession, account: Account): + sub = (await db.execute( + select(Subscription).where(Subscription.account_id == account.id) + )).scalar_one_or_none() + if sub is None: + raise HTTPException(status_code=404, detail="No subscription for account") + + pl = (await db.execute( + select(PlanLimits).where(PlanLimits.plan == sub.plan) + )).scalar_one_or_none() + pb = (await db.execute( + select(PlanBilling).where(PlanBilling.plan == sub.plan) + )).scalar_one_or_none() + + # Resolved feature flags: plan_feature_defaults overridden by account_feature_overrides + defaults = (await db.execute( + select(PlanFeatureDefault, FeatureFlag) + .join(FeatureFlag, PlanFeatureDefault.flag_id == FeatureFlag.id) + .where(PlanFeatureDefault.plan == sub.plan) + )).all() + resolved = {flag.flag_key: pfd.enabled for pfd, flag in defaults} + overrides = (await db.execute( + select(AccountFeatureOverride, FeatureFlag) + .join(FeatureFlag, AccountFeatureOverride.flag_id == FeatureFlag.id) + .where(AccountFeatureOverride.account_id == account.id) + )).all() + for ovr, flag in overrides: + resolved[flag.flag_key] = ovr.enabled + + return { + "subscription": { + "status": sub.status, + "plan": sub.plan, + "current_period_start": sub.current_period_start, + "current_period_end": sub.current_period_end, + "cancel_at_period_end": sub.cancel_at_period_end, + "seat_limit": sub.seat_limit, + "has_pro_entitlement": sub.has_pro_entitlement, + "is_paid": sub.is_paid, + }, + "plan_billing": pb, + "plan_limits": _plan_limits_to_dict(pl) if pl else {}, + "enabled_features": resolved, + } + + +def _plan_limits_to_dict(pl: PlanLimits) -> dict: + return {c.name: getattr(pl, c.name) for c in pl.__table__.columns} +``` + +- [ ] **Step 3: Endpoint** + +```python +# backend/app/api/endpoints/billing.py — append: +@router.get("/state", response_model=BillingStateResponse) +async def get_billing_state( + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> BillingStateResponse: + state = await BillingService.get_billing_state(db, current_user.account) + return BillingStateResponse(**state) +``` + +- [ ] **Step 4: Tests** + +```python +# backend/tests/test_billing_state_endpoint.py +import pytest +from sqlalchemy import select +from app.models.subscription import Subscription +from app.models.feature_flag import FeatureFlag, PlanFeatureDefault + + +@pytest.mark.asyncio +async def test_billing_state_returns_subscription_plan_features(authed_client, db_session, test_account): + db_session.add(Subscription(account_id=test_account.id, plan="pro", status="active")) + flag = FeatureFlag(flag_key="psa_integration", display_name="PSA Integration") + db_session.add(flag) + await db_session.flush() + db_session.add(PlanFeatureDefault(plan="pro", flag_id=flag.id, enabled=True)) + await db_session.commit() + + response = await authed_client.get("/api/v1/billing/state") + assert response.status_code == 200 + body = response.json() + assert body["subscription"]["status"] == "active" + assert body["subscription"]["plan"] == "pro" + assert body["subscription"]["has_pro_entitlement"] is True + assert body["enabled_features"]["psa_integration"] is True + + +@pytest.mark.asyncio +async def test_billing_state_account_override_beats_plan_default(authed_client, db_session, test_account): + from app.models.feature_flag import AccountFeatureOverride + db_session.add(Subscription(account_id=test_account.id, plan="pro", status="active")) + flag = FeatureFlag(flag_key="escalation_mode", display_name="Escalation Mode") + db_session.add(flag) + await db_session.flush() + db_session.add(PlanFeatureDefault(plan="pro", flag_id=flag.id, enabled=False)) + db_session.add(AccountFeatureOverride(account_id=test_account.id, flag_id=flag.id, enabled=True)) + await db_session.commit() + + response = await authed_client.get("/api/v1/billing/state") + assert response.json()["enabled_features"]["escalation_mode"] is True +``` + +- [ ] **Step 5: Run + commit** + +```bash +docker exec resolutionflow_backend pytest backend/tests/test_billing_state_endpoint.py -v --override-ini="addopts=" +git add backend/app/services/billing.py backend/app/api/endpoints/billing.py backend/app/schemas/billing.py backend/tests/test_billing_state_endpoint.py +git commit -m "feat(billing): add GET /billing/state aggregating subscription + plan + features" +``` + +--- + +## Phase H — Pilot user backfill + +### Task 25: Pilot complimentary backfill migration + +**Files:** +- Create: `backend/alembic/versions/_subscriptions_pilot_complimentary_backfill.py` +- Test: `backend/tests/test_pilot_complimentary_backfill.py` + +- [ ] **Step 1: Migration** + +```bash +docker exec -w /app resolutionflow_backend alembic revision -m "subscriptions pilot complimentary backfill" +``` + +```python +def upgrade() -> None: + """Set status='complimentary' and plan='pro' for all existing accounts that + don't have an active or canceled subscription. Pilot users transition to + permanent complimentary Pro per spec section 5. + + Forward-only — does not preserve original status values.""" + conn = op.get_bind() + # Update existing rows: + conn.execute(sa.text(""" + UPDATE subscriptions + SET status = 'complimentary', plan = 'pro', + current_period_end = NULL, current_period_start = NULL, + updated_at = now() + WHERE status NOT IN ('canceled', 'past_due') + """)) + # Backfill: any account without a Subscription row gets one + conn.execute(sa.text(""" + INSERT INTO subscriptions (id, account_id, plan, status, created_at, updated_at) + SELECT gen_random_uuid(), a.id, 'pro', 'complimentary', now(), now() + FROM accounts a + WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.account_id = a.id) + """)) + + +def downgrade() -> None: + raise RuntimeError( + "Cannot downgrade: original subscription state is not preserved. " + "Restore from backup if needed." + ) +``` + +- [ ] **Step 2: Test** + +```python +# backend/tests/test_pilot_complimentary_backfill.py +import pytest +from sqlalchemy import select +from app.models.account import Account +from app.models.subscription import Subscription + + +@pytest.mark.asyncio +async def test_after_backfill_all_existing_accounts_are_complimentary(db_session): + """Run the backfill SQL inline, then verify every account has a + complimentary Pro Subscription. Skipped in normal pytest because the + migration runs at deploy time — this test exists for cutover validation.""" + accounts = (await db_session.execute(select(Account))).scalars().all() + if not accounts: + pytest.skip("no accounts seeded") + for account in accounts: + sub = (await db_session.execute( + select(Subscription).where(Subscription.account_id == account.id) + )).scalar_one() + assert sub.status in ("complimentary", "canceled", "past_due") # canceled/past_due preserved + if sub.status == "complimentary": + assert sub.plan == "pro" + assert sub.is_paid is False + assert sub.has_pro_entitlement is True + + +@pytest.mark.asyncio +async def test_complimentary_subscription_passes_active_subscription_guard(authed_client, db_session, test_account): + db_session.add(Subscription(account_id=test_account.id, plan="pro", status="complimentary")) + await db_session.commit() + response = await authed_client.get("/api/v1/trees/") + assert response.status_code != 402 +``` + +- [ ] **Step 3: Apply + run** + +```bash +docker exec resolutionflow_backend alembic upgrade heads +docker exec resolutionflow_backend pytest backend/tests/test_pilot_complimentary_backfill.py -v --override-ini="addopts=" +``` + +- [ ] **Step 4: Commit** + +```bash +git add backend/alembic/versions/*_subscriptions_pilot_complimentary_backfill.py backend/tests/test_pilot_complimentary_backfill.py +git commit -m "feat(billing): pilot user backfill — set existing accounts to complimentary" +``` + +--- + +## Final Phase 1 Validation + +### Task 26: Full test sweep + verify dark-launch posture + +**Files:** None (verification only). + +- [ ] **Step 1: Full test suite** + +```bash +docker exec resolutionflow_backend pytest --override-ini="addopts=" +``` + +Expected: all tests pass. Numbers should include: +- All existing pre-Phase-1 tests (regression) +- ~25 new test files added across Phase 1 + +- [ ] **Step 2: Migrations heads** + +```bash +docker exec resolutionflow_backend alembic heads +``` + +Expected: single head. If multiple, inspect — Phase 1 added 9 migrations linearly, so the chain should converge. + +- [ ] **Step 3: Verify endpoints not exposed publicly without flag** + +`SELF_SERVE_ENABLED` is a backend config flag. Check that: +- `/auth/register` — still accepts the existing invite_code-based flow today; new flows (OAuth, email-match) coexist without breaking it. +- `/auth/google/callback`, `/auth/microsoft/callback` — return 503 if `GOOGLE_CLIENT_ID` / `MS_CLIENT_ID` is unset (which they will be in dev until cutover). +- `/billing/checkout-session` — the BillingService raises if Stripe not configured. +- `/billing/state` — requires auth; works for any logged-in user. + +This phase is **dark by default** because the new endpoints are gated by environment configuration, not by `SELF_SERVE_ENABLED`. Phase 2 introduces `SELF_SERVE_ENABLED` as a frontend gate. + +- [ ] **Step 4: No commit (validation only)** + +If everything passes, Phase 1 is complete. Phase 2 (frontend + cutover) follows in a separate plan document. + +--- + +## Self-Review + +(Author's note — fix issues inline, don't re-review.) + +**Spec coverage:** Walk each spec section: + +- §2 Schema additions: oauth_identities ✓ (Task 1), users.password_hash nullable ✓ (Task 2), role_at_signup + onboarding_step_completed ✓ (Task 3), accounts.team_size_bucket + primary_psa ✓ (Task 4), account_invites.revoked_at + email_sent_at ✓ (Task 5), plan_billing ✓ (Task 6), sales_leads + stripe_events ✓ (Task 7). +- §2 Subscription status enum extension to 'complimentary' ✓ (Task 9 — model property changes; underlying column is `String(50)` so no migration value-level change required). +- §4 BillingService methods: start_trial ✓ (Task 13), create_checkout_session ✓ (Task 14), apply_subscription_event ✓ (Task 15), get_billing_state ✓ (Task 24). open_customer_portal — **GAP**: not in Phase 1; deferred to Phase 2 since it's only consumed by the frontend `/account/billing` page. Document this in Phase 2 plan. +- §4 Replace deps.py:109 ✓ (Task 10). +- §4 require_active_subscription ✓ (Task 11). +- §4 require_verified_email_after_grace ✓ (Task 12). +- §4 Stripe webhook handler extension ✓ (Task 16). +- §3.2 OAuth callbacks: Google ✓ (Task 17), Microsoft ✓ (Task 18). Null password_hash handling ✓ (Task 19). +- §3.2 Email-match enforcement at register ✓ (Task 20). Auto-send verification ✓ (Task 20). +- §3.3 Account invite extensions: email send on create ✓ (Task 21), bulk endpoint ✓ (Task 22), soft-revoke ✓ (Task 23). +- §3.4 GET /billing/state ✓ (Task 24). +- §5 Pilot user backfill ✓ (Task 25). + +**Out of Phase 1 scope (correctly deferred to Phase 2):** +- `BillingService.open_customer_portal` and `/billing/portal-session` endpoint — frontend-driven. +- `PATCH /users/me/onboarding-step` endpoint (welcome wizard persistence) — frontend-driven. +- `/sales-leads` endpoint and SalesLead form — frontend-driven (table is in Phase 1 schema). +- `/admin/plan-limits` extension to surface plan_billing — frontend-driven. +- Beta-signup deprecation (307 redirect) — pure routing change, fits Phase 2. +- Frontend pricing page, welcome wizard, dashboard redesign, useBillingStore. +- `SELF_SERVE_ENABLED` flag wiring. +- Stripe live-mode setup + cutover validation. + +**Placeholder scan:** none in tasks. The migration `down_revision = ""` is genuine — that value is filled in at `alembic revision` time by the tool, not by a human guess. + +**Type consistency:** verified — `OAuthProfile.provider_subject`, `OAuthIdentity.provider_subject`, etc. align across files. `BillingService.start_trial` returns `Subscription` consistently. `apply_subscription_event` signature matches webhook handler call site. + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-1-backend.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. + +**Which approach?** diff --git a/docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md b/docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md new file mode 100644 index 00000000..f37b5564 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md @@ -0,0 +1,968 @@ +# Self-Serve Signup & Onboarding — Phase 2: Frontend + Cutover + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. +> +> **Granularity note:** Unlike Phase 1, this plan defines *contracts and acceptance criteria* — not every component detail. Implementers exercise judgment on internal structure (hooks vs. props, file splits, CSS organization) as long as the contracts hold and integration tests pass. Steps use checkbox (`- [ ]`) syntax for tracking; each task is one mergeable PR. + +**Goal:** Layer the user-facing self-serve flow on top of the Phase 1 backend foundation — pricing page, OAuth buttons + register redesign, welcome wizard, dashboard redesign with trial pill + next-step card + checklist, accept-invite page, sales contact form, billing portal — gated behind `SELF_SERVE_ENABLED` and `VITE_SELF_SERVE_ENABLED` until cutover. + +**Architecture:** Frontend reads billing state from a new `useBillingStore` Zustand store fed by `GET /billing/state`. New routes layer on the existing React Router v7 + lazyWithRetry pattern. Wizard state is server-persisted via `PATCH /users/me/onboarding-step`. Authenticated routes mount under existing `AppLayout`; public routes (pricing, contact-sales, accept-invite, verify-email) are top-level. Cutover is two flag flips: backend `SELF_SERVE_ENABLED=true`, frontend `VITE_SELF_SERVE_ENABLED=true`. + +**Tech Stack:** React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config), Zustand (immer + zundo), React Router v7, Axios, Lucide. Backend additions: a few small endpoints (Phase 1 left them out) — see Phase I. + +**Spec reference:** `docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md` (commit `bbb01ef`). +**Phase 1 reference:** `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-1-backend.md`. + +--- + +## Phase Sequencing + +Each phase ends in a mergeable PR. Frontend gates everything behind `VITE_SELF_SERVE_ENABLED` so the new surfaces stay invisible to public users until Phase O cutover. + +| Phase | Tasks | Outcome | +|---|---|---| +| I | 27–31 | Backend endpoints Phase 1 deferred + `SELF_SERVE_ENABLED` flag + `/admin/plan-limits` extension | +| J | 32–34 | Frontend billing foundation: `useBillingStore`, hooks, gating components — proven against Phase 1 backend | +| K | 35–37 | Auth surfaces: register redesign with OAuth buttons, accept-invite page, email-verification surfaces | +| L | 38–39 | Welcome wizard — 3 steps with persistence | +| M | 40–41 | Dashboard redesign — trial pill, next-step card, checklist redesign | +| N | 42–44 | Public surfaces: pricing page, contact-sales form, landing-page CTA, beta-signup 307 | +| O | 45–47 | Cutover: Stripe live-mode setup, internal validation, feature-flag flip | + +--- + +## Phase I — Backend endpoints + admin extension + feature flag + +### Task 27: BillingService.open_customer_portal + GET /billing/portal-session + +**Outcome:** Authed users can request a Stripe-hosted Customer Portal URL for card updates and cancellation. + +**Contract:** + +``` +GET /api/v1/billing/portal-session + → 200 { url: string } + → 503 when STRIPE_SECRET_KEY unset + → 400 when account has no stripe_customer_id (must complete checkout first) +``` + +`BillingService.open_customer_portal(account)` creates a `stripe.billing_portal.Session` with `return_url=$FRONTEND_URL/account/billing` and returns the session URL. + +**Acceptance criteria:** + +- [ ] Endpoint mounted at `/billing/portal-session` and is in the `_SUBSCRIPTION_GUARD_ALLOWLIST` and `_EMAIL_VERIFICATION_ALLOWLIST` (so it works for canceled / unverified-past-grace users who need to update billing). +- [ ] Returns 400 with `{"error": "no_stripe_customer"}` when `account.stripe_customer_id is None`. +- [ ] Stripe call mocked via `respx`; happy-path test asserts shape `{url: ...}`. + +**Integration test added:** + +- `test_billing_portal_returns_url_for_account_with_stripe_customer` + +**Commit:** `feat(billing): add BillingService.open_customer_portal + GET endpoint` + +--- + +### Task 28: PATCH /users/me/onboarding-step + +**Outcome:** Welcome wizard can persist Step 1/2/3 state to the server. + +**Contract:** + +``` +PATCH /api/v1/users/me/onboarding-step + body: { + step: 1 | 2 | 3, + action: "complete" | "skip", + data?: { + // step 1 + company_name?: string, + team_size_bucket?: "1-2"|"3-5"|"6-10"|"11-25"|"26+", + role_at_signup?: "owner"|"lead_tech"|"tech"|"other", + // step 2 + primary_psa?: "connectwise"|"autotask"|"halopsa"|"none", + // step 3 has no data — invitations posted separately to /accounts/me/invites/bulk + }, + } + → 200 { onboarding_step_completed: int, onboarding_dismissed: false } +``` + +Writes: +- step=1 + action=complete → `accounts.name`, `accounts.team_size_bucket`, `users.role_at_signup`, `users.onboarding_step_completed=1` +- step=1 + action=skip → `users.onboarding_step_completed=1` only (no field writes) +- step=2 → `accounts.primary_psa` (only on complete) + `users.onboarding_step_completed=2` +- step=3 → `users.onboarding_step_completed=3` (the actual invites POST is separate) + +Validates: `step` cannot decrease; `action="skip"` ignores the `data` payload. + +**Endpoint also exposes a sibling:** `POST /users/me/onboarding-dismiss-rest` → sets `users.onboarding_dismissed=TRUE`. Used by "Skip the rest" button. + +**Acceptance criteria:** + +- [ ] In `_EMAIL_VERIFICATION_ALLOWLIST` (so users can move through the wizard before verifying email). +- [ ] In `_SUBSCRIPTION_GUARD_ALLOWLIST` (wizard runs during the trial; never gated). +- [ ] Refusing to decrease `step` is enforced (a step=2 PATCH followed by step=1 returns 400). +- [ ] Tests cover: complete with data writes fields; skip without data only advances step; idempotent re-PATCH of same step. + +**Integration tests added:** + +- `test_onboarding_step1_complete_writes_account_name_and_team_size_and_role` +- `test_onboarding_step2_skip_advances_without_psa` +- `test_onboarding_step_cannot_decrease` +- `test_onboarding_dismiss_rest_sets_flag` + +**Commit:** `feat(onboarding): add PATCH /users/me/onboarding-step + dismiss-rest` + +--- + +### Task 29: POST /sales-leads endpoint + +**Outcome:** Public Talk-to-sales form has somewhere to post. + +**Contract:** + +``` +POST /api/v1/sales-leads + body: { + email: string, + name: string, + company: string, + team_size?: string, + message?: string, + source: "pricing_page" | "register_footer" | "landing_page", + posthog_distinct_id?: string, + } + → 201 { id: uuid, status: "received" } +``` + +Public — no auth required. Rate-limit: max 5 submissions per IP per hour (use existing `core.rate_limit`). + +Side effects: +1. Insert `sales_leads` row. +2. Fire-and-forget `EmailService.send_sales_lead_notification` to `settings.SALES_LEAD_RECIPIENT_EMAIL` (new env var, default `sales@resolutionflow.com`). +3. Emit PostHog server-side event `talk_to_sales_form_submitted` with `source` property. + +**Acceptance criteria:** + +- [ ] Anti-spam: rate-limited per IP. +- [ ] Email send failure doesn't fail the request (logged warning). +- [ ] Sales-lead recipient email is configurable; defaults to a placeholder until cutover. + +**Integration tests:** + +- `test_sales_lead_creates_row_and_sends_notification_email` +- `test_sales_lead_rate_limited_after_5_per_hour` + +**Commit:** `feat(sales): add POST /sales-leads public endpoint` + +--- + +### Task 30: Extend /admin/plan-limits to surface plan_billing fields + +**Outcome:** Super-admins can manage plan_billing (Stripe IDs, display names, prices, public/archived flags) via the same admin page they already use. + +**Contract change:** + +``` +GET /api/v1/admin/plan-limits → list[PlanLimitWithBillingResponse] + +PlanLimitWithBillingResponse extends PlanLimitResponse with: + display_name?: string + description?: string + monthly_price_cents?: int | null + annual_price_cents?: int | null + stripe_product_id?: string | null + stripe_monthly_price_id?: string | null + stripe_annual_price_id?: string | null + is_public?: bool + is_archived?: bool + sort_order?: int + +PUT /api/v1/admin/plan-limits accepts the same fields; updates plan_billing +in the same transaction. If a plan_billing row doesn't exist for the plan, +PUT creates it. +``` + +**Acceptance criteria:** + +- [ ] Single PUT round-trips both `plan_limits` and `plan_billing` in one transaction. +- [ ] Cache invalidation: `app.state.billing_cache` flushed for all accounts on the affected plan. +- [ ] No new admin page in v1 — existing `/admin/plan-limits` UI just gets new form fields. + +**Integration tests:** + +- `test_admin_plan_limits_get_includes_plan_billing_fields_when_present` +- `test_admin_plan_limits_put_creates_plan_billing_row` +- `test_admin_plan_limits_put_invalidates_billing_cache` + +**Commit:** `feat(admin): extend /admin/plan-limits to manage plan_billing fields` + +--- + +### Task 31: Wire SELF_SERVE_ENABLED feature flag + +**Outcome:** A single flag controls whether the new public-facing self-serve flow is exposed. + +**Contract:** + +Backend: +- `settings.SELF_SERVE_ENABLED: bool = False` (already added in Phase 1 Task 14). +- New endpoint `GET /api/v1/config/public` (no auth) returns `{self_serve_enabled: bool, oauth_providers: ["google", "microsoft"] | []}` — frontend reads this once at load. + +Frontend: +- `VITE_SELF_SERVE_ENABLED` env var (build-time bake-in per Lesson 60). +- New `useAppConfig` hook: prefers backend `/config/public` response, falls back to `VITE_SELF_SERVE_ENABLED` for build-time gating. +- Public routes (`/pricing`, `/contact-sales`, `/accept-invite`, OAuth callbacks) return 404 from the frontend router when `self_serve_enabled === false`. +- Register page hides OAuth buttons + invite-code-removed copy when flag is off (preserves the existing invite-code-required register flow). + +**Acceptance criteria:** + +- [ ] Flag is OFF by default in all envs except where explicitly enabled. +- [ ] When OFF: existing `/auth/register` invite-code flow still works exactly as today. +- [ ] When ON: new flows are reachable; invite-code requirement is removed (the field still exists in the schema for backward-compat but the gate-check accepts NULL). + +**Integration tests:** + +- `test_get_config_public_returns_self_serve_flag` +- `test_register_invite_code_required_when_self_serve_disabled` (regression) +- `test_register_invite_code_optional_when_self_serve_enabled` + +**Commit:** `feat(config): add SELF_SERVE_ENABLED flag + GET /config/public` + +--- + +## Phase J — Frontend billing foundation + +### Task 32: useBillingStore Zustand store + GET /billing/state integration + +**Outcome:** Frontend has a single source of truth for subscription / plan / feature state. + +**Contract — store shape:** + +```typescript +// frontend/src/store/billingStore.ts +interface BillingState { + subscription: { + status: 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'complimentary' + plan: string + current_period_start: string | null // ISO + current_period_end: string | null // ISO + cancel_at_period_end: boolean + seat_limit: number | null + has_pro_entitlement: boolean + is_paid: boolean + } | null + planBilling: { + display_name: string + description: string | null + monthly_price_cents: number | null + annual_price_cents: number | null + } | null + planLimits: Record + enabledFeatures: Record + isLoading: boolean + error: string | null +} + +interface BillingStore extends BillingState { + fetch: () => Promise + refetch: () => Promise + reset: () => void +} +``` + +**Behavior:** + +- Auto-fetches on auth-store login (subscribe to `authStore`). +- Auto-resets on logout. +- Polls every 60s while the dashboard is mounted (simple `useInterval` in a top-level component is fine — no SSE for v1). +- `refetch()` is exposed for explicit refresh after Stripe Checkout success-redirect. + +**Acceptance criteria:** + +- [ ] Initial state is null/empty; populates after first successful fetch. +- [ ] 401 from `/billing/state` triggers logout via existing axios interceptor (no special handling needed). +- [ ] Polling disabled when no user is logged in. + +**Integration tests (Vitest):** + +- `useBillingStore fetches on login and populates subscription` +- `useBillingStore resets on logout` +- `useBillingStore refetch overwrites stale data` + +**Commit:** `feat(billing): add useBillingStore and /billing/state integration` + +--- + +### Task 33: useFeature, useFeatureLimit, useTrialBanner hooks + +**Outcome:** Components can ask "is this feature on?" / "how many sessions left?" / "what stage is the trial in?" without re-implementing the read. + +**Contract — hook signatures:** + +```typescript +// useFeature: enabled boolean for a feature key +function useFeature(flagKey: string): boolean + +// useFeatureLimit: progress against a quantitative limit +function useFeatureLimit(field: keyof PlanLimits): { + used: number // from /api/v1/usage/{field} (lazy fetch, cached 60s) + limit: number | null + percentage: number | null // null when limit is null (unlimited) + isAtLimit: boolean + isLoading: boolean +} + +// useTrialBanner: derives stage from subscription state +function useTrialBanner(): { + stage: 'pristine' | 'warning' | 'urgent' | 'expired' | 'complimentary' | 'paid' | 'past_due' | 'canceled' | null + daysRemaining: number | null +} +``` + +**Stage derivation:** +- `subscription.status === 'complimentary'` → `complimentary` +- `subscription.status === 'active'` → `paid` +- `subscription.status === 'past_due'` → `past_due` +- `subscription.status === 'canceled'` → `canceled` +- `subscription.status === 'trialing'` AND `current_period_end > now()` → `pristine` (>3 days), `warning` (1–3), `urgent` (<1) +- `subscription.status === 'trialing'` AND `current_period_end <= now()` → `expired` + +**Acceptance criteria:** + +- [ ] `useFeatureLimit` does NOT block render — returns `isLoading=true` until usage data arrives. +- [ ] `useTrialBanner` returns `null` when subscription is null (no flicker on initial load). +- [ ] All three hooks subscribe to `useBillingStore` such that updates propagate without manual refetch. + +**Integration tests (Vitest):** + +- `useFeature returns false when flag absent` +- `useFeatureLimit transitions isLoading → loaded` +- `useTrialBanner stage matches subscription state matrix` + +**Commit:** `feat(billing): add useFeature, useFeatureLimit, useTrialBanner hooks` + +--- + +### Task 34: FeatureGate, UpgradePrompt, EmailVerificationGate components + +**Outcome:** Three drop-in components that handle the most common gating patterns. Component implementation details (props, layout, Tailwind classes) are at implementer's discretion as long as the API holds. + +**Contracts:** + +```tsx +// FeatureGate: render children if feature enabled, else fallback (default ) +}> + + + +// UpgradePrompt: standardized "this feature is on Pro" affordance with CTA + // resolves display name + plan name internally + +// EmailVerificationGate: wraps protected content; renders past grace + + + +``` + +**Behavior:** + +- `` reads from `useFeature(feature)`. Server-side check via `require_feature` is the security boundary; this is UX. +- `` CTA links to `/account/billing/select-plan`. +- `` reads `users.email_verified_at` + `users.created_at` from `authStore.user`. Day 1–6 unverified renders children (banner shown elsewhere). Day 7+ unverified renders ``. + +**Acceptance criteria:** + +- [ ] All three components are exported from `frontend/src/components/common/`. +- [ ] No CSS-in-JS — Tailwind classes per existing pattern. +- [ ] Lock icon + greyed style for `` matches the design system tokens (no `bg-accent` for non-interactive elements per design lessons). + +**Integration tests (Vitest + Playwright):** + +- `FeatureGate renders children when flag enabled, fallback when disabled` (Vitest) +- `UpgradePrompt CTA navigates to /account/billing/select-plan` (Vitest) +- `EmailVerificationGate renders wall on day 8 unverified user` (Vitest, mocked authStore) + +**Commit:** `feat(billing): add FeatureGate, UpgradePrompt, EmailVerificationGate components` + +--- + +## Phase K — Auth surfaces + +### Task 35: Register page redesign with OAuth buttons + invite-code-optional + +**Outcome:** New register flow supports email+password OR Google OR Microsoft, with promo code field collapsed (deferred per spec) and the legacy invite-code field invisible when `SELF_SERVE_ENABLED`. + +**Contract:** + +Frontend route stays `/register`. Component lives at `frontend/src/pages/RegisterPage.tsx` (modified, not replaced). + +Top-of-page CTAs: +- **"Continue with Google"** button → opens OAuth window → on callback, POSTs `code` to `POST /api/v1/auth/google/callback` → stores tokens via existing auth-store login flow → redirects to `/welcome` (new user) or `/` (returning). +- **"Continue with Microsoft"** button → same shape against `/auth/microsoft/callback`. +- **"or sign up with email"** divider, then existing email + password form. + +Removed/conditional: +- **Invite-code field** — hidden when `useAppConfig().self_serve_enabled === true`. When the flag is off, the existing required-invite-code flow runs unchanged. +- **Promo-code field** — not in v1 (deferred per spec). UI should NOT include it. + +`/register?plan=pro` query param is captured into `localStorage` (`rf-intended-plan`) so `BillingService.start_trial` (already runs on Pro by default) can later be enriched OR the in-app picker can preselect. + +**Acceptance criteria:** + +- [ ] Email+password register call still works; auto-sends verification email per Phase 1 Task 20. +- [ ] OAuth callback creates User + Account + Subscription per Phase 1 Task 17/18; lands on `/welcome`. +- [ ] When self-serve disabled: invite-code flow visible, OAuth buttons hidden. +- [ ] When self-serve enabled: invite-code field hidden, OAuth buttons visible. +- [ ] Existing test users (`engineer@resolutionflow.example.com` etc.) can still log in via `/login` unchanged. + +**Integration tests (Playwright):** + +- `register email+password → verification email queued → land on /welcome` +- `register via Google OAuth (mocked provider) → land on /welcome` +- `register page hides OAuth + shows invite-code field when self_serve_enabled is false` + +**Commit:** `feat(auth): redesign /register with OAuth buttons; hide invite-code under flag` + +--- + +### Task 36: AcceptInvitePage at /accept-invite?code=... + +**Outcome:** Invitee from email can join an existing account with set-password OR Google OR Microsoft. + +**Contract:** + +New top-level route `/accept-invite?code=<32-char-code>`. Component at `frontend/src/pages/AcceptInvitePage.tsx`. + +Flow: +1. On mount, `GET /api/v1/accounts/invites/{code}/lookup` (NEW endpoint — see acceptance criteria) returns `{account_name, inviter_name, invited_email, role}` or 404/410 (expired/revoked/used). +2. Render: "Join {account_name} on ResolutionFlow" + email locked to `invited_email` + three sign-in options (set password, Google, Microsoft). +3. On submit, POST to existing `/auth/register` with `account_invite_code` and the email matching `invited_email` (per Phase 1 Task 20 enforcement). +4. OAuth path: launch provider with state including the invite code; callback POSTs `{code, account_invite_code, invited_email}` to handle linking. +5. Success → land on `/?welcome=teammate` (suppresses welcome wizard for invitees per spec). + +**Backend addition needed (small):** + +``` +GET /api/v1/accounts/invites/{code}/lookup + → 200 {account_name, inviter_name, invited_email, role} + → 404 invite_invalid_or_expired_or_revoked +``` + +This is a public endpoint (no auth) reading account-scoped data, so uses `_admin_session_factory()` per the Phase 4 RLS pattern. + +**Acceptance criteria:** + +- [ ] Invalid/expired/revoked codes show a clear "ask {inviter} to resend" message with a link to email the inviter (via `mailto:`). +- [ ] Email field is locked to `invited_email` — frontend doesn't even render an editable input. +- [ ] OAuth path requires the provider's email to match `invited_email`; mismatch returns the same `invite_email_mismatch` error from Phase 1. +- [ ] Successful accept lands on `/?welcome=teammate`; the dashboard shows a "Welcome to {account_name}" toast and a checklist with "Setup shop" + "Invite a teammate" auto-marked done. + +**Integration tests (Playwright):** + +- `accept invite with email/password → join existing account → land on /?welcome=teammate` +- `accept invite with Google OAuth (matching email) → land on dashboard` +- `accept invite with mismatched email → see invite_email_mismatch error` +- `accept invite with expired code → see resend message` + +**Commit:** `feat(auth): add /accept-invite page + lookup endpoint` + +--- + +### Task 37: Email verification surfaces — banner, wall, /verify-email route + +**Outcome:** UI for the soft 7-day grace + day-7 wall. + +**Contract:** + +- **``** — thin top-of-dashboard bar visible when `users.email_verified_at IS NULL` AND grace not expired. "Resend" link calls existing `POST /auth/email/send-verification`. +- **``** — full-content replacement when grace expired. Same "Resend" CTA + a "Sign out" button. +- **`/verify-email?token=...`** — frontend route that calls existing `POST /auth/email/verify` and shows success/error state. On success, refreshes the auth store and redirects to `/?verified=1` toast. + +**Acceptance criteria:** + +- [ ] Banner contrasts well in dark theme (use `bg-warning-dim` per design tokens, not custom colors). +- [ ] Wall has a "Sign out" button so a user with a typo'd email can recover. +- [ ] Verification success toast does not double-fire on remount. +- [ ] If user is already verified when hitting `/verify-email`, the page shows "Already verified" rather than failing. + +**Integration tests (Playwright):** + +- `unverified day-1 user sees banner on dashboard` +- `unverified day-8 user sees wall, can sign out, can resend` +- `clicking verification link verifies and redirects to dashboard with toast` + +**Commit:** `feat(auth): add email verification banner, wall, /verify-email page` + +--- + +## Phase L — Welcome wizard + +### Task 38: Wizard scaffold + Step 1 (Your shop) + +**Outcome:** Authed users at `/welcome` see a deliberate first-impression flow that captures shop context. + +**Routing:** + +``` +/welcome → redirects to next incomplete step or "/" if done +/welcome/step-1 → "Your shop" +/welcome/step-2 → "Your PSA" +/welcome/step-3 → "Invite your team" +``` + +A top-level `` reads `users.onboarding_step_completed` + `users.onboarding_dismissed` from authStore and dispatches: + +| State | Redirect | +|---|---| +| `onboarding_dismissed === true` | `/` | +| `onboarding_step_completed >= 3` | `/` | +| `onboarding_step_completed === null/0` | `/welcome/step-1` | +| `onboarding_step_completed === 1` | `/welcome/step-2` | +| `onboarding_step_completed === 2` | `/welcome/step-3` | + +**Step 1 fields (per spec):** + +- Company name (pre-filled from `accounts.name`, editable) +- Team size: select from `1-2 / 3-5 / 6-10 / 11-25 / 26+` +- Your role: select from `Owner / Lead Tech / Tech / Other` + +**Step 1 actions:** +- **Continue** → PATCH `/users/me/onboarding-step` `{step: 1, action: "complete", data: {...}}` → `/welcome/step-2` +- **Skip** → PATCH `{step: 1, action: "skip"}` → `/welcome/step-2` +- **Skip the rest** → POST `/users/me/onboarding-dismiss-rest` → `/` + +**Acceptance criteria:** + +- [ ] Each navigation persists state server-side before transition; refresh resumes correctly. +- [ ] Skip-the-rest is a quiet text link, not a primary button. +- [ ] Email-verification banner is visible above the wizard if user is unverified (banner persists into wizard). + +**Integration tests (Playwright):** + +- `new user lands on /welcome/step-1 after register` +- `step-1 Continue with all fields filled persists and advances` +- `step-1 Skip-the-rest dismisses and lands on /` +- `refresh in middle of step-1 returns to step-1 with prior data still in form (or empty if not yet saved)` + +**Commit:** `feat(onboarding): add welcome wizard scaffold + Step 1 (Your shop)` + +--- + +### Task 39: Wizard Steps 2 (Your PSA) and 3 (Invite team) + +**Outcome:** Wizard is complete; users can finish or skip individual steps. + +**Step 2 fields (per spec):** + +- PSA selection: tiles for `ConnectWise / Autotask / HaloPSA / No PSA yet`. Selecting one shows a quiet inline "Connect now" link that navigates to `/account/integrations` (out of wizard). + +**Step 3 fields (per spec):** + +- Email input rows × 3, with "+ Add another" up to 10 max +- Per-row role select: default "Tech" (maps to `engineer`), with "Viewer" option +- "Skip" and "Skip the rest" links + +**Step 3 submit behavior:** + +- POST `/api/v1/accounts/me/invites/bulk` with the populated rows. +- Then PATCH `/users/me/onboarding-step` `{step: 3, action: "complete"}`. +- On success → `/?welcome=true` (shows a "You're all set" toast). +- Bulk endpoint's `failed[]` array displayed inline next to the failed email; user can retry. + +**Acceptance criteria:** + +- [ ] Step 2 default action is "Continue" (not "Connect now"); the inline credential entry is intentionally NOT in the wizard. +- [ ] Step 3 invites are sent (email send happens server-side per Phase 1 Task 22). +- [ ] Empty Step 3 + Skip = no invites sent; step still advances. +- [ ] Each step's persistence is independent — navigating back via browser back button respects `onboarding_step_completed`. + +**Integration tests (Playwright):** + +- `step-2 select ConnectWise → continue → primary_psa is set in /billing/state-equivalent or /auth/me` +- `step-3 enter 2 emails → invites visible in /accounts/me/invites + emails sent` +- `step-3 with one bad email shows partial success, user can retry` +- `wizard end-to-end: register → step-1 → step-2 → step-3 → dashboard with success toast` + +**Commit:** `feat(onboarding): add wizard Steps 2 (PSA) and 3 (Invite team)` + +--- + +## Phase M — Dashboard redesign + +### Task 40: Topbar trial pill + email verification banner integration + +**Outcome:** Every authed page shows the right billing-state pill in the topbar. + +**Contract — `` placement:** + +Mounts inside `AppLayout` topbar. Reads `useTrialBanner()`: + +| Stage | Pill | +|---|---| +| `pristine` | "Pro trial · Nd" — info color | +| `warning` (≤3d) | "Pro trial · Nd" — warning amber | +| `urgent` (≤1d) | "Pro trial · today" — urgent (warning amber, slightly more saturated) | +| `expired` | "Trial expired — pick a plan" — clickable → `/account/billing/select-plan` | +| `paid` | tier display name (e.g., "Pro") — quiet | +| `complimentary` | "Complimentary Pro" — friendly tag, no CTA | +| `past_due` | "Payment failed — update card" — clickable → `/account/billing` | +| `canceled` | "Reactivate" — clickable → `/account/billing/select-plan` | +| `null` | hidden | + +**Acceptance criteria:** + +- [ ] Color tokens are existing design-system tokens (`--accent` / `--warning` / etc.) — no custom colors. +- [ ] Pill is keyboard-focusable for clickable variants. +- [ ] EmailVerificationBanner from Task 37 sits BELOW the topbar, ABOVE main content. Both can coexist. +- [ ] Mobile: pill collapses to icon + tooltip when topbar is too narrow. + +**Integration tests (Playwright):** + +- `complimentary user sees "Complimentary Pro" pill` +- `trialing user with 12 days remaining sees "Pro trial · 12d"` +- `expired-trial user sees clickable "Trial expired" pill` +- `past_due user sees clickable "Payment failed" pill` + +**Commit:** `feat(dashboard): add TrialPill in AppLayout topbar` + +--- + +### Task 41: Next-step card + checklist redesign + dashboard wiring + +**Outcome:** Dashboard surfaces a single "next thing to do" card; full checklist available behind a toggle. Replaces the existing `OnboardingChecklist` component. + +**Contract:** + +- **``** at top of dashboard content (below banner). Reads from existing `/users/onboarding-status` payload (extended in Phase 1 to drop SOLO/TEAM split — see Phase 1 Task wiring if needed; if not done, do it here). +- Shows the highest-priority incomplete item with a primary CTA button. Items in priority order: + 1. Verify your email (only if unverified — hidden for OAuth signups) + 2. Set up your shop (`onboarding_step_completed >= 1`) + 3. Run your first FlowPilot session (existing `ran_session` check) + 4. Connect your PSA (existing `connected_psa` check) + 5. Invite a teammate (extend existing `invited_teammate` check) + 6. Pick a plan — surfaces near trial end (only when stage is `warning` / `urgent` / `expired`) +- Below the card, "Show all setup steps" toggle expands a full checklist view (single list, no SOLO/TEAM split per spec). + +**OnboardingChecklist component changes:** + +- Remove `SOLO_ITEMS` / `TEAM_ITEMS` split — single unified list. +- Drop the stale `tried_ai_assistant` / "Check out the Script Builder" item entirely. +- Add "Pick a plan" item that shows when trial-banner stage is `warning` or later. + +**Backend addition:** + +`/api/v1/users/onboarding-status` (existing endpoint) response shape extended: + +```python +class OnboardingStatus(BaseModel): + # existing + created_flow: bool + ran_session: bool + exported_session: bool + invited_teammate: bool + connected_psa: bool + is_team_user: bool # KEEP — internal logic only; no UI bifurcation + dismissed: bool # users.onboarding_dismissed + # NEW + email_verified: bool + shop_setup_done: bool # users.onboarding_step_completed >= 1 + # REMOVED from new code paths (kept in payload for backward-compat during deploy): + # tried_ai_assistant: bool +``` + +**Acceptance criteria:** + +- [ ] Old `OnboardingChecklist` widget is replaced wholesale on the dashboard route. Other pages that referenced it (none found in current code, but confirm via grep) are updated or unaffected. +- [ ] Next-step card disappears when all items are done OR `onboarding_dismissed=TRUE`. +- [ ] No SOLO/TEAM bifurcation in the checklist UI. +- [ ] Stale "Script Builder" item is gone. + +**Integration tests (Playwright):** + +- `dashboard for new user surfaces "Verify your email" as next step` +- `after verifying, next step is "Set up your shop"` +- `after wizard step 1, next step is "Run your first FlowPilot session"` +- `"Show all setup steps" expands to a 6-item list with no SOLO/TEAM headers` +- `Pick-a-plan appears at trial day 12, urgent at day 13, primary at day 14` + +**Commit:** `feat(dashboard): replace checklist with next-step card + unified list` + +--- + +## Phase N — Public surfaces + +### Task 42: Pricing page (B-style) at /pricing + +**Outcome:** Public pricing page lives at `/pricing`, gated by feature flag. + +**Contract:** + +Public route. Component at `frontend/src/pages/PricingPage.tsx`. Reads `plan_billing` data via a new public endpoint: + +``` +GET /api/v1/plans/public + → 200 [ + { + plan: string, + display_name: string, + description: string | null, + monthly_price_cents: number | null, + annual_price_cents: number | null, + max_seats: number | null, // from plan_limits + sort_order: number, + is_public: true, // filtered server-side + }, + ... + ] +``` + +Page sections (per spec B): +1. Hero (one-liner + reverse-trial reassurance) +2. Three plan cards (Starter / Pro recommended / Enterprise) — Pro card has "Recommended" badge; Enterprise card has "Talk to sales" CTA → `/contact-sales` +3. Comparison table (which features in which plan) — driven by feature flag display names +4. Single testimonial slot (placeholder until real testimonial available) +5. Trust strip — security/compliance copy + +**Acceptance criteria:** + +- [ ] Returns 404 when `self_serve_enabled === false`. +- [ ] Plan cards show prices from `plan_billing.monthly_price_cents`. Enterprise card hides price. +- [ ] "Start free trial" buttons on Starter/Pro link to `/register?plan=pro` (or starter). +- [ ] "Talk to sales" on Enterprise links to `/contact-sales`. +- [ ] Trust strip claims should be honest — see spec open-risks #7 (GDPR DPA) and #7b (SOC2). If those aren't ready by cutover, copy in this task uses softer language (e.g., "Built on Stripe + AWS · Encrypted in transit and at rest"). + +**Integration tests (Playwright):** + +- `unauth user sees pricing page when self_serve_enabled is true` +- `pricing page → "Start free trial" → /register?plan=pro` +- `pricing page → "Talk to sales" → /contact-sales` +- `pricing page returns 404 when self_serve_enabled is false` + +**Commit:** `feat(pricing): add /pricing page (B-style)` + +--- + +### Task 43: Talk-to-sales form at /contact-sales + landing-page CTA + +**Outcome:** Enterprise prospects have a clear path; `LandingPage.tsx` gets a "See pricing" CTA. + +**Contract:** + +`/contact-sales` route with form posting to `POST /sales-leads` (Phase I Task 29). + +Form fields: +- Name (required) +- Work email (required) +- Company (required) +- Team size (select; same buckets as wizard Step 1 + a "more than 26" option) +- "What brought you here?" (textarea, optional) +- Submit button + +After submit: +- Confirmation page: "Thanks — we'll reach out within 1 business day. Want to skip ahead? [Calendly link]" +- Calendly link is a config string (`VITE_CALENDLY_URL`); when unset, link section is hidden. + +`LandingPage.tsx` modification: +- Add a prominent "See pricing" CTA near the existing "Get started" CTA. +- Both visible regardless of `self_serve_enabled` (see-pricing 404s if flag off, landing keeps existing behavior). Actually: gate the See-pricing CTA behind `useAppConfig().self_serve_enabled` so we don't show a button that 404s. + +**Acceptance criteria:** + +- [ ] Form blocks duplicate submissions client-side (disable button while in flight). +- [ ] PostHog `talk_to_sales_form_submitted` event fires with `source: 'pricing_page' | 'landing_page'` based on referrer. +- [ ] Calendly link block hides when `VITE_CALENDLY_URL` unset. + +**Integration tests (Playwright):** + +- `submit /contact-sales form → see confirmation page → /sales-leads has new row` +- `landing page shows "See pricing" CTA when self_serve_enabled, hides when off` + +**Commit:** `feat(sales): add /contact-sales form + landing page CTA` + +--- + +### Task 44: Beta-signup deprecation + +**Outcome:** The legacy `beta_signup.py` endpoint redirects to register; existing waitlist gets a heads-up email. + +**Contract:** + +- `POST /api/v1/beta-signup` (existing) → keep mounted but return `307 Temporary Redirect` to `/register?from=beta`. +- One-off admin script `scripts/email_beta_waitlist.py` that reads existing `beta_signup` table and queues "we've launched" emails to each. +- Don't drop the table; archive in place. + +**Acceptance criteria:** + +- [ ] Existing tests against `/beta-signup` either updated to expect 307 or removed. +- [ ] Script is idempotent (uses an `email_sent_at` field on the beta-signup row, adding it via migration if needed). + +**Integration tests:** + +- `POST /beta-signup returns 307 to /register?from=beta` + +**Commit:** `feat(sales): redirect beta-signup to /register; queue waitlist emails` + +--- + +## Phase O — Cutover + +### Task 45: Stripe live-mode setup checklist (manual) + +**Outcome:** Stripe live-mode is configured and matches test mode. Manual step; this task tracks completion. + +**Checklist:** + +- [ ] In Stripe Dashboard (live mode): + - [ ] Create Products: ResolutionFlow Starter, ResolutionFlow Pro, ResolutionFlow Enterprise. + - [ ] Create monthly + annual recurring Prices for Starter and Pro. + - [ ] Enterprise has no Prices in the catalog (sales-created per customer). + - [ ] Enable Customer Portal: update payment method, cancel subscription, view invoices. Disable plan-switching from the portal. + - [ ] Register webhook endpoint at `https://api.resolutionflow.com/api/v1/webhooks/stripe` with events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`, `invoice.payment_succeeded`. + - [ ] Save the live webhook signing secret. +- [ ] In Railway prod environment variables: + - [ ] `STRIPE_SECRET_KEY` (live mode key, `sk_live_...`) + - [ ] `STRIPE_WEBHOOK_SECRET` (live signing secret) + - [ ] `STRIPE_PUBLISHABLE_KEY` (live publishable key) → `VITE_STRIPE_PUBLISHABLE_KEY` for frontend builds + - [ ] `OAUTH_REDIRECT_BASE` = `https://resolutionflow.com` + - [ ] `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` for prod Google OAuth app (separate from dev/test) + - [ ] `MS_CLIENT_ID` / `MS_CLIENT_SECRET` for prod Microsoft OAuth app +- [ ] Run `python -m scripts.sync_stripe_plan_ids` (Phase 1 Task 6 referenced; create if not existing) to populate `plan_billing` rows with live Stripe IDs: + - [ ] Pro monthly + annual price IDs + - [ ] Starter monthly + annual price IDs (if Starter is in scope; see open risk #14) + - [ ] Enterprise: stripe_product_id only, no price IDs + +**Acceptance criteria:** + +- [ ] Live webhook receives a test event (use Stripe CLI's `stripe trigger checkout.session.completed` against the live endpoint with a test customer) and is logged in `stripe_events`. +- [ ] `plan_billing` rows query returns expected Stripe IDs for Pro tier. + +**No commit** — this is a deploy-time operation. + +--- + +### Task 46: Internal validation pass (test mode → soft cutover via per-email allowlist) + +**Outcome:** Real flow exercised end-to-end against the prod backend with `SELF_SERVE_ENABLED=false`, gated to internal testers only. + +**Per-email allowlist mechanism:** + +Backend reads `INTERNAL_TESTER_EMAILS` env var (comma-separated). When `SELF_SERVE_ENABLED=false` AND `current_user.email` is in the list, treat the user as if the flag were on (e.g., bypass invite-code requirement, expose pricing page via a header check). For frontend, the `/config/public` endpoint returns `self_serve_enabled: true` for these specific authenticated users. + +**Validation scenarios:** + +- [ ] Email signup → wizard step-by-step → first FlowPilot session run → trial-end synthetic time (DB query: `UPDATE subscriptions SET current_period_end = now() - interval '1 day' WHERE account_id = ...`) → plan picker → Stripe Checkout (test card `4242 4242 4242 4242`) → webhook → status='active'. +- [ ] Google sign-in (real Google account) → `/welcome` → wizard → dashboard. +- [ ] Microsoft sign-in (real M365 account) → same flow. +- [ ] Invite-by-email: existing tester invites a teammate → teammate receives email → clicks link → `/accept-invite` → set password → joins account → lands on `/?welcome=teammate`. +- [ ] Email match enforcement: try to register with `account_invite_code` and a different email → see `invite_email_mismatch`. +- [ ] Past-due simulation: use Stripe test card `4000 0000 0000 0341` → first invoice succeeds, next charge declines → `subscription_status='past_due'` → topbar pill changes → user can update card via Customer Portal. +- [ ] Pilot complimentary: log in as an existing pilot account → see "Complimentary Pro" pill, no walls, no nudges. +- [ ] Webhook signature failure: send a forged webhook → 400 + log entry. +- [ ] OAuth-only user attempts password login: rejected with `use_oauth_provider`. + +**Acceptance criteria:** + +- [ ] All 9 scenarios pass in prod test mode with internal testers. +- [ ] Errors logged during validation are reviewed and either fixed or documented. + +**No commit** — validation is a checklist of test runs. + +--- + +### Task 47: Feature-flag flip + week-1 monitoring + +**Outcome:** `SELF_SERVE_ENABLED=true` and `VITE_SELF_SERVE_ENABLED=true` in prod. Public pricing page is live. + +**Cutover steps:** + +- [ ] Send pre-launch email to all pilot users via `EmailService.send_complimentary_account_announcement` (1-2 days before flip). +- [ ] Schedule the flip during low-traffic hours. +- [ ] Update Railway env vars: `SELF_SERVE_ENABLED=true` (backend), `VITE_SELF_SERVE_ENABLED=true` (frontend, requires redeploy since Vite bakes at build time). +- [ ] Verify prod: pricing page returns 200; new user can register without invite code. +- [ ] Announce launch (founder action; not eng). + +**Week-1 monitoring (PostHog dashboards):** + +- [ ] Funnel: `pricing_page_viewed → register_started → register_completed → email_verification_completed → welcome_wizard_completed → first_session_started` +- [ ] OAuth method mix +- [ ] Wizard skip rate per step +- [ ] `feature_gate_blocked` count by `flag_key` +- [ ] Trial conversion: `trial_modal_shown → checkout_completed` +- [ ] Stripe webhook error rate (Sentry alert if > 1/hour) +- [ ] `subscriptions.is_paid` audit query (manual SQL): confirm complimentary accounts are NOT counted in MRR + +**Rollback plan:** + +- Flip both flags back to `false`. Pricing page → 404. Register page → invite-code-required flow. Pilot complimentary status preserved (benign). +- Stripe webhook handler stays live regardless. +- Forward-only schema means nothing to revert at the DB level. + +**No commit** — this is a deploy + monitor task. + +--- + +## Self-Review + +**Spec coverage check (against `2026-05-05-self-serve-signup-onboarding-design.md`):** + +| Spec section | Covered by | +|---|---| +| §3.1 Pricing page | Task 42 | +| §3.2 Register page redesign with OAuth + invite-code-optional | Task 35 | +| §3.3 Welcome wizard (3 steps) | Tasks 38, 39 | +| §3.4 Dashboard with topbar pill + next-step card | Tasks 40, 41 | +| §3.5 Email verification surfaces | Task 37 | +| §3.6 Trial-end conversion (in-app modal day 10, wall day 14) | Task 41 covers checklist; the modal is part of Task 40's TrialPill stage transitions + the dashboard's modal trigger via `useTrialBanner` — implementer's discretion to add a `` component if it emerges naturally | +| §3.7 Plan picker → Stripe Checkout | Frontend page at `/account/billing/select-plan` lives within the dashboard area; Task 41's "Pick a plan" CTA navigates there. Component exists in scope of Task 40/41 — implementer's call on whether to split into its own file. | +| §3.8 Past-due / dunning | Task 40 (TrialPill `past_due` stage) + Customer Portal link | +| §3.9 Sales lead | Tasks 29, 43 | +| §3.10 Owner transfer (existing) | No new task — surface in Account → Team page during dashboard work, implementer's discretion | +| §4 BillingService.open_customer_portal | Task 27 | +| §4 PATCH /users/me/onboarding-step | Task 28 | +| §4 GET /billing/state consumed by frontend | Task 32 | +| §4 useFeature/useFeatureLimit/useTrialBanner | Task 33 | +| §4 FeatureGate / UpgradePrompt | Task 34 | +| §4 Caching invalidation triggered from /admin/plan-limits | Task 30 | +| §5 Beta-signup deprecation | Task 44 | +| §5 SELF_SERVE_ENABLED dark launch | Task 31 | +| §5 Stripe live-mode setup | Task 45 | +| §5 Internal validation phase | Task 46 | +| §5 Cutover + monitoring | Task 47 | + +**Gaps and judgment-calls (called out for implementer):** + +- **`` (day-10 in-app modal)** — left to implementer to decide whether it's its own task or rolled into Task 40. Spec is clear about behavior; component split is style. +- **Plan picker page (`/account/billing/select-plan`)** — frontend page that calls `POST /billing/checkout-session` and redirects. Lives within Task 40/41 area; not its own task. Acceptance: "user can pick Starter/Pro + seats and be redirected to Stripe Checkout." +- **Owner-transfer surface in Account → Team page** — existing endpoint, just needs UI. Implementer's call on which task absorbs this. +- **``** — referenced in spec; renders on dashboard route when trial expired. Lives in Task 40/41 area as a render-branch of the dashboard layout. + +**Placeholder scan:** none — every "implementer's discretion" call is bounded by a contract and acceptance criteria. + +**Type/contract consistency:** + +- `BillingState` shape in Task 32 matches `BillingStateResponse` from Phase 1 Task 24. +- `PATCH /users/me/onboarding-step` payload in Task 28 matches the wizard's writes in Tasks 38, 39. +- OAuth callback contract in Task 35 matches Phase 1 Task 17/18 endpoint shapes. +- `` in Task 34 reads from authStore; `` in Task 40 reads from `useBillingStore`. Different sources, intentional (verification is on `User`, trial is on `Subscription`). + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md`.** + +This plan is intentionally higher-altitude than Phase 1: contracts and acceptance criteria, not component-detail walkthroughs. Implementers exercise judgment on internal structure as long as contracts hold and integration tests pass. + +**Recommended execution sequence:** + +1. **Phase 1 first** (`2026-05-06-self-serve-signup-phase-1-backend.md`). Phase 2 depends on its endpoints. +2. After Phase 1 lands, **execute Phase 2 phases I → O sequentially**. Each phase is one or a few mergeable PRs. +3. **Cutover (Phase O)** is gated by Phase 1 + Phase 2 both green in prod test mode. + +**Two execution options for Phase 2:** + +**1. Subagent-Driven (recommended)** — fresh subagent per task with two-stage review. Higher-altitude tasks pair well with this since the subagent has room to make local design decisions inside the contract. + +**2. Inline Execution** — execute tasks in a long-running session using executing-plans, with checkpoints between phases. + +**Which approach?** diff --git a/docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md b/docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md new file mode 100644 index 00000000..0da81a33 --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md @@ -0,0 +1,904 @@ +# Self-Serve Signup & Onboarding — Design Spec + +**Date:** 2026-05-05 +**Status:** Draft (revised after review-findings pass; pending user re-review) +**Author:** Michael Chihlas + Claude + +--- + +## Overview + +Open ResolutionFlow to public self-serve signup with a 14-day reverse trial on Pro, Stripe-backed billing, a sales-assist lane for Enterprise, and a hybrid onboarding flow (3-step welcome wizard + dashboard with next-step card). The current invite-code-gated registration is removed; existing pilot users transition to a permanent `subscriptions.status='complimentary'` state. **The billing layer reuses existing infrastructure** (`subscriptions` + `plan_limits` + `feature_flags` + `plan_feature_defaults` + `account_feature_overrides` + `account_invites` + `email_verification_tokens`) — this spec adds only what's missing, not parallel structures. + +--- + +## Decisions Made + +| Question | Decision | +|---|---| +| Trigger for redoing signup/onboarding | Open self-serve channel (D); must look trustworthy; must hook into payment processor cleanly | +| Trial / payment model | A + E — reverse trial (14 days, no card upfront) + sales-assist lane for Enterprise | +| Plan structure | Two self-serve tiers (Starter, Pro) per seat + sales-assist Enterprise. Defined via existing `plan_limits.plan` keys + a new `plan_billing` sibling table (Stripe IDs, prices, public catalog metadata). | +| Payment processor | Stripe with hosted Checkout; no provider abstraction | +| Auth strategy | Stay with custom auth. Extend existing email verification (auto-send on register, 7-day soft grace + dashboard wall). Add Google + Microsoft via new `oauth_identities` table; `users.password_hash` becomes nullable with explicit OAuth-only handling in login/change-password/reset. Extend existing `account_invites` (enforce email match at register, wire `EmailService` into create/bulk). | +| Signup form scope | A — minimal form (treat all signups as team-of-1) | +| Plan choice timing | X — defer; trial runs on full Pro; picker shown around day 12 and at trial-end | +| Feature gating | **Reuse existing `feature_flags` + `plan_feature_defaults` + `account_feature_overrides`.** Admin via existing `/admin/plan-limits` + `/admin/feature-flags` endpoints. No new combined `/admin/plans` surface in v1. | +| Onboarding shape | C — hybrid (3-step welcome wizard then dashboard with checklist) | +| Welcome wizard layout | V2 — narrative 3 steps (Your shop, Your PSA, Invite your team) | +| Dashboard first-run | C — topbar trial pill + single "next step" card (full checklist behind a "Show all" toggle) | +| Email verification | Soft, 7-day grace, hard wall day 7; skipped entirely for OAuth signups (provider-attested). **Reuses existing `email_verification_tokens` table + `/auth/email/send-verification` + `/auth/email/verify`.** Backend enforcement via new `require_verified_email_after_grace` dep with path allowlist (auth, profile, billing) returns 403 when grace expires unverified. Frontend `` is a UX layer over the same rule. | +| Pricing page | B — pricing + light marketing context (comparison table + testimonial slot + trust strip) | +| Trial-end conversion flow | A — quiet days 1-9, gentle nudges 10-13, hard wall day 14 with plan picker | +| Trial expiry enforcement | **Replace `deps.py:109` auto-downgrade.** Expiry is computed at request time (`status='trialing' AND current_period_end < now()`); no mutation to `plan='free'`. New backend `require_active_subscription` dep with path allowlist returns 402 when locked. | +| `is_paid` semantics | `subscriptions.is_paid` excludes `complimentary` so comp accounts don't inflate paid/MRR metrics. New `has_pro_entitlement` property covers "this account can access Pro features" (true for paid Pro + complimentary Pro + active trial). | +| Billing state surface | **Separate `GET /billing/state` endpoint** feeding a new frontend `useBillingStore`. `/auth/me` stays user-focused. | +| Teammate invite-accept | Set-password OR Google/Microsoft OAuth; email-locked **(enforced at `/auth/register` against `account_invites.email`)**; no welcome wizard for invitees. | +| Existing pilot users | All transitioned to `subscriptions.status='complimentary'` on Pro — no nags, no walls, voluntary conversion path. | +| Existing invite codes | Registration gate removed. Table preserved for historical pilots; `User.invite_code_id` retained for existing rows; not consumed at new signups. **No repurposing.** | +| Promo codes | **Deferred from v1.** Add a new `promo_codes` table later if a launch campaign needs them. | + +--- + +## Section 1 — System overview + +### What this delivers + +Public registration through `/pricing` → `/register` → `/welcome` → dashboard, with the billing substrate built almost entirely on existing infrastructure. New code is concentrated in (a) the OAuth surface, (b) Stripe-aware billing service + webhook handler, (c) the welcome wizard + dashboard redesign, and (d) the public-facing pricing page. + +### Four chunks of work + +1. **Front-of-funnel** — public `/pricing` page (B-style: comparison table + testimonial slot + trust strip), sales-lead capture form, reworked `/register` form with OAuth options. +2. **Onboarding surfaces** — 3-step welcome wizard (V2: shop → PSA selection → invite team) firing immediately after register; redesigned dashboard with topbar trial pill + single "next-step" card (C-style); 6-item checklist (Verify email → Setup shop → Run first session → Connect PSA → Invite teammate → Pick a plan). +3. **Billing integration over existing schema** — extend `plan_limits` with a sibling `plan_billing` table (Stripe IDs + public catalog metadata); seed Starter / Pro / Enterprise rows in `plan_limits`; seed `feature_flags` + `plan_feature_defaults` for the Pro/Starter gating split; add `subscriptions.status='complimentary'` value; replace `deps.py:109` trial-expiry mutation with computed checks; add a `BillingService`, Stripe webhook handler, and `require_active_subscription` dep. Reuses existing `/admin/plan-limits` and `/admin/feature-flags` admin surfaces. +4. **Auth additions** — Google + Microsoft OAuth via a new `oauth_identities` table (`users.password_hash` becomes nullable). Extend existing `email_verification_tokens` flow with auto-send on register and a 7-day soft-grace dashboard wall. Extend existing `account_invites` to enforce email match at registration and to actually send the invitation email at create-time (today only resend sends). + +### What stays the same + +- Existing JWT auth + JTI refresh rotation +- `Account` / `Team` / `User` model and the `is_super_admin` / `account_role` / `is_team_admin` permission hierarchy (with `account_role` enum `'owner' | 'admin' | 'engineer' | 'viewer'`) +- Phase 4 RLS (subscription state lives on `subscriptions`, account-scoped — RLS rules already configured for it) +- All product surfaces (FlowPilot, PSA integrations, sessions, flows) +- `/admin/plan-limits` + `/admin/feature-flags` admin endpoints (extended, not replaced) +- `/accounts/me/transfer-ownership` (existing — covers owner transfer, no longer flagged "out of scope") +- `/accounts/me/invites` and `/me/invites/{id}/resend` (extended with email send + email-match enforcement) + +### What's deprecated + +- Invite-code-as-registration-gate. The `invite_codes` table is preserved (historical foreign keys from `users.invite_code_id`); the gate is removed at `/auth/register`. +- `beta_signup.py` waitlist endpoint becomes a 307 redirect to `/register`. +- The current SOLO/TEAM split in `OnboardingChecklist` (one unified list). +- The "Check out the Script Builder" item mapped to the stale `tried_ai_assistant` key. +- Custom card-collection forms (Stripe Checkout owns this). +- The auto-downgrade-on-expired-trial logic in `deps.py:109` (replaced with non-mutating computed checks). + +### Sequencing principle + +The billing extensions (new columns, new dep, replacing the auto-downgrade) and the Stripe webhook handler are the longest pole and the most unfamiliar surface area. Build it first, ship it dark behind `SELF_SERVE_ENABLED=false`, then layer the funnel and onboarding surfaces once it's stable. Detailed phases live in the implementation plan. + +--- + +## Section 2 — Data model + +### Schema additions (new, small) + +#### `oauth_identities` + +``` +id UUID PK +user_id UUID FK users +provider VARCHAR(20) -- 'google' | 'microsoft' +provider_subject VARCHAR(255) -- provider's stable user id +provider_email_at_link VARCHAR(255) -- email reported by provider at link time +created_at, updated_at TIMESTAMP WITH TIME ZONE +UNIQUE (provider, provider_subject) +INDEX (user_id) +``` + +A user can have zero password (OAuth-only), one password, and 0+ OAuth identities. v1 ships with one identity per user (signup creates one row). Account linking is a future feature with no schema change required. + +#### `plan_billing` (sibling to `plan_limits`) + +``` +plan VARCHAR(50) PK FK plan_limits.plan +display_name VARCHAR(255) NOT NULL +description TEXT NULL +monthly_price_cents INTEGER NULL -- nullable for Enterprise (custom) +annual_price_cents INTEGER NULL +stripe_product_id VARCHAR(255) NULL +stripe_monthly_price_id VARCHAR(255) NULL +stripe_annual_price_id VARCHAR(255) NULL +is_public BOOLEAN NOT NULL DEFAULT TRUE +is_archived BOOLEAN NOT NULL DEFAULT FALSE +sort_order INTEGER NOT NULL DEFAULT 0 +created_at, updated_at TIMESTAMP WITH TIME ZONE +``` + +`plan_limits.plan` stays the canonical plan key. `plan_billing` carries the Stripe + public-catalog metadata. Joined into the existing `/admin/plan-limits` admin endpoint via the response schema (single PUT updates both tables in one transaction). + +#### `sales_leads` + +``` +id UUID PK +email VARCHAR(255) INDEXED +name VARCHAR(255) +company VARCHAR(255) +team_size VARCHAR(20) -- range string from form +message TEXT +source VARCHAR(50) -- 'pricing_page' | 'register_footer' | etc. +posthog_distinct_id VARCHAR(255) NULL +status VARCHAR(20) DEFAULT 'new' -- 'new' | 'contacted' | 'closed' +created_at, updated_at +``` + +Global table. No RLS. + +#### `stripe_events` + +Webhook idempotency log. Global table. + +``` +id VARCHAR(255) PK -- Stripe event id +event_type VARCHAR(100) INDEXED +processed_at TIMESTAMP WITH TIME ZONE +payload_excerpt JSONB +``` + +### Modifications to existing tables + +#### `subscriptions` — extend the status enum + +- New status value: `'complimentary'`. Status enum effectively becomes `'active' | 'trialing' | 'past_due' | 'canceled' | 'incomplete' | 'complimentary'`. The column is `String(50)` so no schema migration is required for the value itself; we update the value-level checks only. +- `Subscription.is_active` already returns `True` for `('active', 'trialing')` — extend to include `'complimentary'`. +- `Subscription.is_paid` (currently `plan in ('pro', 'team')`) → narrow to `plan in ('pro', 'team') AND status NOT IN ('complimentary',)`. Used for revenue / paid-customer / MRR calculations only. +- New `Subscription.has_pro_entitlement` property: returns True for `(plan='pro' AND status IN ('active', 'complimentary'))` OR `(status='trialing' AND current_period_end > now())`. Used for "can this account access Pro features." + +These are model-level Python property changes plus tests; the underlying column type doesn't change. + +#### `users` — additions + +- `email_verified_at` already exists. No add. Email-verification flow uses it. +- `password_hash` — **change `nullable=False` → `nullable=True`** to support OAuth-only users. Migration sets nullable; no data backfill needed (existing rows all have hashes). +- `role_at_signup VARCHAR(50) NULL` — `'owner' | 'lead_tech' | 'tech' | 'other'` (welcome-wizard Step 1 captures this). + +The existing `users.onboarding_dismissed` field stays. **Add a new `users.onboarding_step_completed INTEGER NULL`** that tracks the highest wizard step the user has either completed or explicitly skipped (1, 2, or 3; NULL = haven't started). This is the only new column needed beyond `role_at_signup` and resolves the per-step skip ambiguity that derived data couldn't represent. + +Wizard state model: + +- User clicks **Continue** on a step → `onboarding_step_completed = step_number`. Step's data fields are written (e.g., Step 1 writes `users.role_at_signup` + `accounts.team_size_bucket`). +- User clicks **Skip** on a step → `onboarding_step_completed = step_number`. Step's data fields stay NULL. +- User clicks **Skip the rest** on any step → `users.onboarding_dismissed = TRUE` (whatever step they were on stays as `onboarding_step_completed = step_number - 1`). +- Wizard is "done" when `onboarding_dismissed = TRUE` OR `onboarding_step_completed >= 3`. +- `/welcome` redirect logic: if done, go to `/`; otherwise go to `/welcome/step-{onboarding_step_completed + 1 or 1}`. + +This makes "I intentionally skipped inviting teammates" representable separately from "I haven't reached Step 3 yet." + +#### `accounts` — additions for wizard data + +`accounts.name` (existing, `String(255) NOT NULL`) is reused for the wizard's "Company name" field — the wizard updates this row rather than a new column. Today `accounts.name` is populated at register-time from the user's input or a sensible default; the wizard lets the user correct it. + +New columns: + +- `team_size_bucket VARCHAR(20) NULL` — `'1-2' | '3-5' | '6-10' | '11-25' | '26+'` +- `primary_psa VARCHAR(20) NULL` — `'connectwise' | 'autotask' | 'halopsa' | 'none'` + +No billing state on `accounts` — it lives on `subscriptions`. + +#### `account_invites` — small additions + +- `revoked_at TIMESTAMP WITH TIME ZONE NULL` — distinguishes revoked from used. Current model has only `used_at`; revoke (resend handler at `accounts.py:323`) currently deletes the row. Add `revoked_at` + change resend to soft-revoke for audit trail. +- (Optional) `email_sent_at TIMESTAMP WITH TIME ZONE NULL` — track that the invite email was actually sent (today, only resend sends; create does not). + +`AccountInvite.is_used` and `is_valid` properties extend to consider `revoked_at`. + +### Migrations + +Single Alembic chain — manual revisions per Lesson 77. Multi-head heads on `main` (`070`, `c0f3a4b7e91d`, `024`) currently coexist; the new chain branches from the most recent and merges via `alembic upgrade heads` (plural). + +1. `add_oauth_identities.py` — new table. +2. `users_password_hash_nullable.py` — alter to nullable. +3. `users_add_role_at_signup_and_onboarding_step.py` — add `role_at_signup` and `onboarding_step_completed` columns. +4. `accounts_add_wizard_columns.py` — add `team_size_bucket`, `primary_psa`. (`accounts.name` already exists; wizard writes to it.) +5. `account_invites_add_revoked_at_and_email_sent_at.py` — add columns. +6. `add_plan_billing.py` — new sibling table. Seeds Starter / Pro / Enterprise rows **with `stripe_product_id` / `stripe_*_price_id` left NULL**. Existing `plan_limits` rows already exist for `'free' / 'pro' / 'team'`; this migration aligns keys (`'starter' | 'pro' | 'enterprise'` if we rename, OR keep `'free' / 'pro' / 'team'` and treat `'free'` as the floor — open risk #14 captures the decision). Stripe IDs are populated **out-of-band** per environment via either the existing `/admin/plan-limits` PUT (extended to accept Stripe fields) or a one-off `python -m scripts.sync_stripe_plan_ids` admin command driven by env vars. **Migrations stay environment-agnostic** — they don't read live mode vs. test mode IDs. +7. `seed_pro_starter_feature_flags.py` — register feature keys (`psa_integration`, `escalation_mode`, `script_builder`, `analytics_dashboards`, `knowledge_flywheel`, `team_admin_full`, `monthly_sessions` quantitative, `seats` quantitative, `sso`, `audit_log`) in `feature_flags`; populate `plan_feature_defaults` per the Pro/Starter split. +8. `subscriptions_pilot_complimentary_backfill.py` — `UPDATE subscriptions SET status='complimentary', plan='pro' WHERE status NOT IN ('canceled')` for accounts that exist as of cutover. Single statement; ≤ 100 rows expected. +9. `add_sales_leads_and_stripe_events.py` — two new tables. + +Forward-only. No down-migrations for the data backfills (step 8) — the original status values per account are not preserved. + +### RLS notes + +- `oauth_identities` is account-adjacent (joined via `user_id`), but RLS on `users` is admin-DB-only (per `deps.py` `get_current_user` uses `get_admin_db`). Treat `oauth_identities` the same — no per-tenant RLS policy; queries use admin session. Verify against current `users` table policy before merging. +- `plan_billing` is global (joins `plan_limits.plan`, also global). No RLS. +- `sales_leads`, `stripe_events` are global. No RLS. +- `account_invites` already has its policy (account-scoped). No change. +- `subscriptions` already has its policy. No change to schema means no RLS revision. + +### Index notes + +- `oauth_identities (provider, provider_subject)` UNIQUE — the OAuth callback's primary lookup. +- `oauth_identities (user_id)` — list a user's identities. +- `account_invites (revoked_at)` — partial filter for active-invites queries (`WHERE accepted_by_id IS NULL AND revoked_at IS NULL`). + +--- + +## Section 3 — Funnel walkthrough + +### 1. Acquisition — `/pricing` (public) + +New route. B-style page: hero (one-liner + reverse-trial reassurance), three plan cards (Starter / Pro recommended / Enterprise), comparison table, testimonial slot (placeholder copy until a real one lands), trust strip ("SOC2 in progress · Stripe billing · GDPR DPA available"). Plan card data sourced from `plan_billing` filtered by `is_public=TRUE AND is_archived=FALSE`. + +- **Pro/Starter cards** → "Start free trial" → `/register?plan=pro` (or `?plan=starter`). Query param remembered through OAuth round-trip. +- **Enterprise card** → "Talk to sales" → `/contact-sales` → POST `/sales-leads` → confirmation page with Calendly link in the email. +- Existing `LandingPage.tsx` gets a "See pricing" CTA pointing here. + +### 2. Registration — `/register` (public, redesigned) + +Three sign-up paths on one page: + +- **Google sign-in** (primary button at top) → OAuth round-trip → `/auth/google/callback`. Backend creates a User if first time (`oauth_identities` row + Account + Subscription on Pro trial via `BillingService.start_trial`), marks `email_verified_at = now()` (provider-attested), redirects to `/welcome`. +- **Microsoft sign-in** (button) → same flow with `provider='microsoft'`. +- **Email + password** → POST `/auth/register`. Backend creates User (with `password_hash` set) + Account, calls `BillingService.start_trial`, sends verification email via existing `EmailService.send_email_verification_email` (auto-send is added; today the user has to click a button), returns JWT, frontend redirects to `/welcome`. + +Form fields: full name, work email, password (10+ chars, complexity rules per existing `UserCreate.password_complexity` validator). The current `invite_code` field on `UserCreate` is **removed at the registration gate** — public signups don't need one. The `account_invite_code` field is **kept** for the teammate-accept flow (see step 5b below). + +**Critical fix flagged in review:** registration with `account_invite_code` must enforce `user_data.email == account_invites.email` (today this is not enforced at `/auth/register`). The check happens in the register handler before the User is created; mismatch returns 400 with `{"error": "invite_email_mismatch"}`. + +### 3. Welcome wizard — `/welcome` (authed) + +Dedicated routes: `/welcome/step-1` (Your shop), `/welcome/step-2` (Your PSA), `/welcome/step-3` (Invite team). `/welcome` itself redirects to the lowest-numbered incomplete step. Each step persists immediately (PATCH endpoints — see Appendix A) so refreshes don't lose data and "Skip the rest" lands cleanly. + +- **Step 1 — Your shop**: company name (pre-filled from existing `accounts.name`, editable), team size bucket, your role. Saves to `accounts.name`, `accounts.team_size_bucket`, `users.role_at_signup`. +- **Step 2 — Your PSA**: PSA selection only. Saves to `accounts.primary_psa`. Quiet "Connect now" link → `/account/integrations` (out of wizard); default action is **Continue**. No API key entry inside the wizard. +- **Step 3 — Invite your team**: up to 3 email fields visible with "+ Add another" link; each invite defaults to "Tech" role; fully skippable. POSTs to a new `POST /accounts/me/invites/bulk` (thin wrapper around the existing single-create) **and sends invite emails per row**. The wizard's "Tech" UI label maps to `account_invites.role = 'engineer'` in the DB; "Viewer" UI label maps to `'viewer'` (per the existing CHECK constraint). + +**Critical fix flagged in review:** today, `POST /accounts/me/invites` (`accounts.py:257`) creates the row but does NOT send the email — only `/me/invites/{id}/resend` sends. The new flow wires `EmailService.send_account_invite_email` (existing method at `core/email.py:125`) into both create and bulk paths and stamps `email_sent_at` on success. + +Skip behavior: "Skip" on a step advances `users.onboarding_step_completed` (recording that the user saw and chose to skip that step). A separate "Skip the rest, take me to dashboard" link sets `users.onboarding_dismissed=TRUE` and redirects to `/`. Wizard is "done" when `onboarding_dismissed=TRUE` OR `onboarding_step_completed >= 3`. Auth-store reads this state on app load; `/welcome` redirects to the next incomplete step or to `/` if done. + +**Invited teammate variant:** invitee's email link goes to a frontend `/accept-invite?code=…` route that posts to `/auth/register` with `account_invite_code` (per the existing `UserCreate` schema). They land on `/?welcome=teammate` instead of the wizard, and get a brief "Welcome to {company}'s ResolutionFlow" toast. Re-running the wizard on already-onboarded users is suppressed via `users.onboarding_dismissed` OR derived data presence. + +### 4. Dashboard — `/` (authed, redesigned) + +- **Topbar pill** in `AppLayout` renders based on `subscriptions.status` and `current_period_end`: + - `trialing` AND `current_period_end > now()`: "Pro trial · Nd" — blue, amber when ≤3d remaining, red when ≤1d. + - `trialing` AND `current_period_end <= now()`: "Trial expired — pick a plan" (the locked state — no mutation has occurred at the DB level, just rendered differently). + - `active`: tier name only ("Pro" / "Starter") — no urgency. + - `complimentary`: "Complimentary Pro" — friendly tag, no CTA. + - `past_due`: "Payment failed — update card" — clickable, routes to `/account/billing`. + - `canceled`: pill becomes a "Reactivate" CTA. +- **Next-step card** sits below the topbar. "Show all setup steps" link expands the full 6-item list inline. +- **Email-verification banner** (when `users.email_verified_at IS NULL`): always-visible thin bar above the next-step card with a "Resend" link (POSTs to existing `/auth/email/send-verification`). On day 7 unverified, the dashboard route renders `` instead of normal content. + +Checklist items (same for everyone — no SOLO/TEAM split): + +1. **Verify your email** — auto-completes on link click; hidden if signed up via OAuth. +2. **Set up your shop** — completes when `users.onboarding_step_completed >= 1`. +3. **Run your first FlowPilot session** — the wedge. Highlighted as the headline action when prior items are complete. +4. **Connect your PSA** — auto-completes when first PSA connection saved. Pre-fills the provider based on welcome wizard selection. +5. **Invite a teammate** — auto-completes when first invitation is sent. +6. **Pick a plan** — appears earlier with low emphasis; turns urgent at ≤3 days remaining in trial. + +The stale `tried_ai_assistant` / "Check out the Script Builder" item is dropped entirely. + +### 5. Email verification — existing endpoints, new gating + +- `POST /auth/email/send-verification` (existing, `auth.py:621`) is auto-called by `/auth/register` — today the user has to click a button. +- `POST /auth/email/verify` (existing, `auth.py:662`) consumes the token and sets `users.email_verified_at`. +- The frontend `/verify-email?token=…` route calls the existing endpoint and shows a success or error state. +- New: a frontend gating layer (``) wraps the dashboard route. Day 1-6 unverified shows the soft banner; day 7+ unverified renders ``. +- **Backend enforcement** via the new `require_verified_email_after_grace` dep (Section 4). The frontend wall is UX; the backend dep prevents direct API access by an unverified user past the 7-day grace. Mounted on every protected router; allowlists `/auth/*` (logout, verify, send-verification, password change), `/users/me`, and `/billing/*` so the user can still log out, verify, manage their profile, and convert to paid. + +No new endpoints, no new column. One new backend dep. + +### 6. Trial-end — Days 10-14 + +- **Day 10**: in-app modal once ("Your trial ends in 4 days. Pick a plan to keep going."). Fired by `useTrialBanner` hook reading from `useBillingStore` (which polls `GET /billing/state`); per-user dismiss recorded in localStorage. Email day 10 + day 13 (`EmailService.send_trial_ending`). +- **Day 14**: when `subscriptions.status='trialing'` AND `current_period_end < now()`, the dashboard route renders `` with the plan picker (Starter / Pro radio + seat count input). **No DB mutation occurs** — the lockout is computed at request time. Past sessions remain visible read-only for 30 days after `current_period_end` — computed at render time as `current_period_end + INTERVAL '30 days' < now()`. After that window, sessions are still in the database (no destructive action) but the dashboard hides them behind the wall until billing is added. + +### 7. Plan picker → Stripe Checkout — `/account/billing/select-plan` (authed) + +User picks Starter/Pro + seat count → POST `/billing/checkout-session` → backend calls `stripe.checkout.sessions.create` with: + +- `customer_email` from User +- `line_items` (price_id from `plan_billing` × quantity = seats) +- `mode='subscription'` +- `subscription_data.trial_end = current_period_end` if still in trial (Stripe takes over the trial countdown) +- `success_url=/account/billing?success=1`, `cancel_url=/account/billing/select-plan` + +Frontend redirects to Stripe-hosted Checkout. Stripe `checkout.session.completed` webhook → backend updates `subscriptions.status='active'`, sets `stripe_subscription_id`, `stripe_price_id`, refreshes `current_period_start/end` from the Stripe subscription, sets `seat_limit`. Idempotency via `stripe_events.id`. + +Success URL renders dashboard with "Pro active 🎉" toast. + +### 8. Past-due / dunning + +Stripe `invoice.payment_failed` webhook → `subscriptions.status='past_due'`. Topbar pill flips to "Payment failed — update card" linking to `/account/billing`, which uses Stripe's Customer Portal for card updates and cancellation. Dashboard remains accessible during the dunning window (Stripe default: 4 retries over 3 weeks). Account locks via `require_active_subscription` only at `canceled`. + +### 9. Sales lead — `/contact-sales` (public) + +Form posts to `/sales-leads` → creates row + sends email to `sales@resolutionflow.com` + emits PostHog event. Confirmation page: "Thanks — we'll reach out within 1 business day. Want to skip ahead? [Calendly link]." The Calendly link is a config string, not a calendar integration in v1. + +### 10. Owner transfer (existing — noted) + +Owner transfer is supported via the existing `POST /accounts/me/transfer-ownership` (`accounts.py:150`). The pricing-page Enterprise tier and the Account → Team page in the redesigned dashboard surface this for owners who need to hand off the account. **Not flagged as out-of-scope risk** as it was in the prior draft. + +--- + +## Section 4 — Billing substrate + Stripe integration + +### `app.services.billing.BillingService` + +Single billing module — not a polymorphic provider abstraction. + +```python +class BillingService: + @staticmethod + async def start_trial(db, account: Account) -> Subscription: + """Creates or updates the Subscription row for a new account. + Sets plan='pro', status='trialing', current_period_end=now()+14d. + Called from /auth/register (email path) and OAuth-callback flows. + No Stripe API call yet — Stripe Customer is created lazily at first + checkout.""" + + @staticmethod + async def create_checkout_session(db, account, plan, seats, billing_interval) -> str: + """Returns the Stripe Checkout URL. Creates Stripe Customer if missing + (stores stripe_customer_id on the **Account** row — existing column at + accounts.stripe_customer_id), then builds checkout.sessions.create + with line_items, mode='subscription', subscription_data.trial_end if + still within local trial, success/cancel URLs. Subscription row is + updated by the webhook handler with stripe_subscription_id and + stripe_price_id once checkout completes.""" + + @staticmethod + async def apply_subscription_event(db, event_type: str, payload: dict) -> None: + """Single entry point for every Stripe webhook that mutates subscription + state. Pure function of (event_type, payload) -> DB writes. Called from + the webhook handler after signature verification + idempotency check.""" + + @staticmethod + async def open_customer_portal(account) -> str: + """Returns Stripe-hosted Customer Portal URL for card updates and + cancellation.""" + + @staticmethod + async def get_billing_state(db, account: Account) -> BillingStateResponse: + """Returns the full billing snapshot for /billing/state — subscription + status, plan, plan_billing metadata, plan_limits values, and the + flattened effective feature flags (defaults overridden by + account_feature_overrides).""" +``` + +`account_id` is the canonical local key; Stripe is the canonical remote state; the webhook handler is the bridge. + +### Replacing the trial auto-downgrade + +The existing logic in `deps.py:81-129` mutates `subscriptions` on every request when a trial expires: + +```python +# CURRENT (to be removed): +if subscription.status == "trialing" and subscription.current_period_end < now(): + subscription.plan = "free" + subscription.status = "active" + subscription.current_period_end = None + subscription.current_period_start = None + await db.commit() +``` + +**Replace this entire block with no-op.** Trial expiry becomes a *computed* state. The data stays as `status='trialing'`, `current_period_end` in the past — readable, observable, idempotent. The new `require_active_subscription` dep enforces the lockout. + +If we ever want an explicit `'expired'` status (for analytics observability), it can be added later without changing the semantic of "trialing + past current_period_end = locked." + +### New backend dep — `require_active_subscription` + +```python +_SUBSCRIPTION_GUARD_ALLOWLIST = { + # auth & profile + "/api/v1/auth/me", + "/api/v1/auth/logout", + "/api/v1/auth/password/change", + "/api/v1/auth/email/send-verification", + "/api/v1/auth/email/verify", + # billing surfaces + "/api/v1/billing/state", + "/api/v1/billing/checkout-session", + "/api/v1/billing/portal-session", + # users own profile + "/api/v1/users/me", + "/api/v1/users/me/onboarding-step", + # read-only history (pattern match: /sessions and /trees in GET only) +} + +async def require_active_subscription( + request: Request, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_admin_db), +) -> Subscription: + """Enforces 'this account currently has access.' Mounted on routers that + require Pro entitlement. Returns the Subscription row when allowed; raises + 402 with structured payload when locked.""" + + if request.url.path in _SUBSCRIPTION_GUARD_ALLOWLIST: + return None # bypass + + sub = await _get_subscription_for_account(db, current_user.account_id) + if not sub: + raise HTTPException(402, detail={"error": "no_subscription"}) + + is_live = ( + sub.status in ("active", "complimentary") + or ( + sub.status == "trialing" + and sub.current_period_end is not None + and sub.current_period_end > datetime.now(timezone.utc) + ) + or sub.status == "past_due" # dunning grace — Stripe retries + ) + if not is_live: + raise HTTPException( + status_code=402, + detail={ + "error": "subscription_inactive", + "status": sub.status, + "plan": sub.plan, + "current_period_end": sub.current_period_end.isoformat() if sub.current_period_end else None, + "upgrade_url": "/account/billing/select-plan", + }, + ) + + return sub +``` + +Mounted on every router under `/api/v1/` *except* the explicit allowlist. GET endpoints for past sessions/trees during the 30-day read-only post-expiry window need a softer variant — see Section 3 step 6 for the read-only contract. Implementation plan will identify each protected endpoint specifically. + +### New backend dep — `require_verified_email_after_grace` + +Mirror of `require_active_subscription`, but for email verification. The frontend `` is a UX layer; this dep is the security layer that prevents an unverified user from bypassing the wall by hitting product APIs directly. + +```python +_EMAIL_VERIFICATION_ALLOWLIST = { + # auth & session + "/api/v1/auth/me", + "/api/v1/auth/logout", + "/api/v1/auth/email/send-verification", + "/api/v1/auth/email/verify", + "/api/v1/auth/password/change", + # users own profile + "/api/v1/users/me", + # billing — let user manage subscription even if email unverified + "/api/v1/billing/state", + "/api/v1/billing/checkout-session", + "/api/v1/billing/portal-session", +} + +VERIFICATION_GRACE_DAYS = 7 + +async def require_verified_email_after_grace( + request: Request, + current_user: User = Depends(get_current_active_user), +) -> None: + """Enforces 'this user has verified their email, OR is still inside the + 7-day grace from account creation.' OAuth signups bypass cleanly because + /auth/google/callback and /auth/microsoft/callback set + users.email_verified_at = now() (provider-attested). + Mounted on every protected router *except* the explicit allowlist.""" + + if request.url.path in _EMAIL_VERIFICATION_ALLOWLIST: + return + + if current_user.email_verified_at is not None: + return + + grace_ends = current_user.created_at + timedelta(days=VERIFICATION_GRACE_DAYS) + if datetime.now(timezone.utc) < grace_ends: + return # still inside grace + + raise HTTPException( + status_code=403, + detail={ + "error": "email_not_verified", + "grace_ended_at": grace_ends.isoformat(), + "resend_url": "/api/v1/auth/email/send-verification", + }, + ) +``` + +Differs from `require_active_subscription` in three ways: + +- **403 (Forbidden) rather than 402 (Payment Required)** — verification is identity, not billing. Lets the frontend interceptor route to a verification CTA, distinct from the upgrade CTA. +- **No DB read** — uses fields already on the `current_user` row from `get_current_active_user`. Cheap. +- **Allowlist includes `/billing/*`** — an unverified user past day 7 should still be able to convert to paid (verification gates feature use, not billing). The verification banner persists into Checkout if needed. + +The two guards compose: most routers depend on **both** `require_active_subscription` AND `require_verified_email_after_grace`. The implementation plan will identify each protected router specifically; both guards are non-optional for product surfaces. + +### Stripe webhook handler — `POST /api/v1/webhooks/stripe` + +A stub already exists at `app/api/endpoints/webhooks.py` with signature verification + an early-out when `settings.stripe_enabled=False`. This work extends the stub — does not replace it — by wiring concrete event handlers, idempotency tracking, and `BillingService.apply_subscription_event` integration. + +- Public endpoint; signature verification is the only gate. +- Reads `Stripe-Signature` header → `stripe.Webhook.construct_event(payload, sig, STRIPE_WEBHOOK_SECRET)` → 400 on mismatch. +- **Idempotency**: every event recorded in `stripe_events` keyed by Stripe's event id. If the row exists, return 200 immediately. +- Uses `_admin_session_factory()` — no `current_account_id` is set during webhook processing (Phase 4 RLS pattern). +- **Replay protection**: Stripe signatures embed a timestamp; reject if older than 5 min. + +Events handled: + +| Event | Action | +|---|---| +| `checkout.session.completed` | Activate: `subscriptions.status='active'`, set `subscriptions.stripe_subscription_id`, `subscriptions.stripe_price_id`, `subscriptions.current_period_start/end`, `subscriptions.seat_limit` from session line_items. (`accounts.stripe_customer_id` was set earlier at `create_checkout_session` time.) | +| `customer.subscription.updated` | Reflect plan changes / period transitions / seat updates | +| `customer.subscription.deleted` | `status='canceled'`, lock via `require_active_subscription` | +| `invoice.payment_failed` | `status='past_due'` | +| `invoice.payment_succeeded` | Confirm `status='active'` after dunning recovery | +| Other | Log and ack 200 | + +### Backend feature-gate dep — `require_feature` + +Reads from the existing 3-table chain (no new tables). **`require_feature` internally composes with `require_active_subscription`** — feature gating without subscription gating would let canceled/expired-trial accounts pass feature checks. They are not independent. + +```python +async def require_feature(flag_key: str): + async def _dep( + sub: Subscription = Depends(require_active_subscription), + user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_admin_db), + ) -> None: + # require_active_subscription has already verified the account is live; + # sub is the live Subscription row. Now check the feature flag. + flag = await _resolve_flag(db, user.account_id, sub.plan, flag_key) + if not flag.enabled: + raise HTTPException( + status_code=402, + detail={ + "error": "feature_not_in_plan", + "feature": flag_key, + "current_plan": sub.plan, + "upgrade_url": "/account/billing/select-plan", + }, + ) + return _dep + + +async def _resolve_flag(db, account_id, plan_key, flag_key): + """Resolve effective feature flag value: + 1. account_feature_overrides for (account_id, flag_key) -> if exists, use that + 2. else plan_feature_defaults for (plan, flag_key) -> use that + 3. else default disabled + """ +``` + +Used as `Depends(require_feature("psa_integration"))` on PSA endpoints, Escalation Mode, Script Builder, Analytics endpoints. The 402-with-payload pattern lets the frontend route the user to `/account/billing/select-plan`. + +For quantitative limits (sessions per month, AI builds): existing `plan_limits` columns (`max_sessions_per_month`, `max_ai_builds_per_month`, etc.) already cover these. Use a sibling helper: + +```python +async def require_within_limit(field: str): + """e.g., field='max_sessions_per_month' — checks current usage against + the resolved plan_limits value, with account-override consulting via + /admin/plan-limits/account-overrides table.""" +``` + +This is closer to the existing `get_user_plan_limits` helper (`core/subscriptions.py`) and reuses that path. + +### Caching strategy + +- Subscription row, plan_limits row, plan_billing row, and resolved feature flag map: cached in `app.state.billing_cache` keyed by `account_id`. TTL 5 minutes. +- Explicit invalidation triggers: + - Stripe webhook handler when `subscriptions` state changes (account-keyed invalidation). + - `/admin/plan-limits` PUT (invalidate **all** accounts on that plan, since plan-wide limits / billing fields changed). + - `/admin/plan-limits/account-overrides` POST/PUT/DELETE (account-keyed). + - `/admin/feature-flags` PUT/DELETE on flag definitions (full-cache flush). + - `/admin/feature-flags/plan-defaults` PUT (invalidate **all** accounts on that plan). + - `/admin/feature-flags/account-overrides` POST/DELETE (account-keyed). +- For Railway multi-worker: per-process cache. The 5-minute TTL bounds inconsistency. Acceptable for v1; revisit with Redis pubsub if we run > 2 workers. + +### Frontend — `useBillingStore` + `GET /billing/state` + +``` +GET /billing/state -> { + subscription: { + status: 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'complimentary', + plan: 'starter' | 'pro' | 'enterprise', + current_period_start: ISODateTime | null, + current_period_end: ISODateTime | null, + cancel_at_period_end: boolean, + seat_limit: number | null, + has_pro_entitlement: boolean, + is_paid: boolean, + }, + plan_billing: { + display_name: string, + monthly_price_cents: number | null, + annual_price_cents: number | null, + }, + plan_limits: { + max_trees, max_sessions_per_month, max_users, ...all current PlanLimits fields + }, + enabled_features: Record, -- flat resolved map +} +``` + +Frontend hooks: + +- `useFeature(key: string): boolean` — reads `enabled_features[key]` from `useBillingStore`. +- `useFeatureLimit(key): { used, limit, percentage, isAtLimit }` — combines `plan_limits[key]` with a lazy `/usage/{key}` count. +- `useTrialBanner(): { stage: 'pristine' | 'warning' | 'urgent' | 'expired', daysRemaining }` — derived from `subscription.status` + `current_period_end`. +- `}>...children` — wrapper for whole-section gating. + +`useBillingStore` is a Zustand store with: +- Initial fetch on auth-store login. +- Refetch on webhook-driven server-sent events (or, for v1, polling every 60s while the dashboard is mounted). +- Manual `refetchBilling()` exposed for use after Stripe Checkout success-redirect. + +`/auth/me` and `UserResponse` stay user-focused — no billing data embedded. + +### Admin UI — reuse existing surfaces + +- `/admin/plan-limits` — extended to also surface `plan_billing` fields in the editor (single PUT round-trips both tables in one transaction). +- `/admin/feature-flags` — unchanged. Toggling a flag's `plan_feature_defaults` enables/disables the feature for that plan tier. +- `/admin/feature-flags/account-overrides` — unchanged. Used for sales-negotiated grants, comp accounts, kill-switching a feature for one customer. + +No new combined `/admin/plans` admin page in v1. + +### Failure modes + +| Scenario | Outcome | +|---|---| +| User abandons Stripe Checkout | No webhook fires; `subscriptions.status` stays `trialing`; trial-end wall fires normally on day 14 via `require_active_subscription` | +| Webhook arrives before app reconciles local state | `stripe_events` idempotency makes this safe | +| Webhook secret rotated | Old webhook attempts 400 until env var redeployed | +| Concurrent webhooks for the same subscription | DB row-level locks on the `subscriptions` row serialize updates; idempotency check is the first read in the transaction | +| Stripe outage during checkout | `BillingService.create_checkout_session` raises; frontend shows "Couldn't start checkout — try again" toast | +| Account on `complimentary` accidentally hits a webhook (e.g., admin manually attached a Stripe customer) | Handler transitions to whatever Stripe says; admin can revert via DB or via `/admin/plan-limits/account-overrides` if needed | +| OAuth-only user attempts `/auth/login` (password) | Login endpoint rejects with 400 `{"error": "use_oauth_provider", "providers": ["google"]}` so frontend can route them to the right button | +| OAuth-only user attempts `/auth/password/change` | Endpoint rejects with 400 — must set initial password via a separate `/auth/password/set-initial` flow (out of scope for v1; OAuth users stay OAuth-only) | +| OAuth-only user requests password reset | Reset email is suppressed; user is shown "Sign in with {provider}" instead | + +--- + +## Section 5 — Migration plan + +### Pre-deploy: Stripe configuration + +Manual setup, separate per environment. + +**Status note (2026-05-05):** Stripe **test mode** Products + Prices + webhook endpoint + test env vars in Railway are already configured. Live-mode setup remains for cutover. + +For each environment: + +1. **Stripe Dashboard**: + - Create Products: `ResolutionFlow Starter`, `ResolutionFlow Pro`, `ResolutionFlow Enterprise` (no public price). + - Create Prices for Starter/Pro: monthly + annual recurring. + - Enable **Customer Portal** with: update payment method, cancel subscription, view invoices. Disable plan-switching from the portal. + - Register webhook endpoint at `https://api.resolutionflow.com/api/v1/webhooks/stripe` with the events listed in Section 4. Save the signing secret. +2. **Railway env vars** (per environment): + - `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PUBLISHABLE_KEY` (frontend; needs `ARG`+`ENV` in `frontend/Dockerfile` per Lesson 60). + +### Schema migration + +Manual revisions per Lesson 77. New chain branches from the most recent of `main`'s heads (`070`, `c0f3a4b7e91d`, `024`) and merges via `alembic upgrade heads`. Migration filenames are listed in Section 2. + +Forward-only. + +### Pilot user transition + +- Migration step 8 sets `subscriptions.status='complimentary'`, `plan='pro'` for all existing accounts (≤ 100 rows). Single statement. +- **Outbound communication**: a single email from `EmailService.send_complimentary_account_announcement` to every pilot user 1-2 days before cutover: + > *"We're opening ResolutionFlow up for new signups. Your account is now a Complimentary Pro account — nothing changes for you. You'll see a small "Complimentary Pro" tag in the app instead of any trial pill. Thanks for piloting."* +- **In-app first-login toast** (optional; ship without if scope tightens): per-browser via localStorage key `rf-complimentary-announcement-seen-{user_id}`. + +### Existing invite-code disposition + +- `invite_codes` table preserved. +- `User.invite_code_id` foreign keys preserved for historical pilots. +- Registration handler (`/auth/register`) drops the invite_code-required gate. The `UserCreate.invite_code` field stays in the schema for backward compatibility but is ignored at registration. No new validations against the `invite_codes` table at signup. +- No promo-code repurposing. Invite codes simply stop being consumed. + +### Beta-signup deprecation + +- `beta_signup.py` endpoint stays mounted but returns 307 redirect to `/register?from=beta`. +- Existing waitlist rows: send a "we've launched — come on in" email with a one-time `from=beta` link. Preserve the table; do not drop. + +### Deploy ordering — dark launch then cutover + +1. **Backend deploy with `SELF_SERVE_ENABLED=false`**: all new endpoints exist (webhook handler, billing, OAuth callbacks, sales-leads, bulk invite, billing/state). `/auth/register` retains the existing invite-code requirement. `/pricing` returns 404. Webhook handler is **live**. +2. **Frontend deploy with `VITE_SELF_SERVE_ENABLED=false`**: new surfaces are routed but hidden behind the flag. +3. **Stripe live-mode configuration in prod** (manual, ~30 min). +4. **Internal validation (1-2 days)**: founder + any teammates use a per-email allowlist to enable self-serve for their accounts only. Tests cover: email signup, OAuth signup paths, invitation accept (with email-match enforcement), pilot complimentary view, past-due simulation via Stripe test cards, subscription guard for locked accounts. +5. **Cutover**: flip `SELF_SERVE_ENABLED=true` and `VITE_SELF_SERVE_ENABLED=true` in prod. Pricing page goes live. +6. **Week 1 monitoring**: PostHog funnel; webhook logs; error rates. + +### Rollback strategy + +- Schema is forward-only — no down-migration for the backfills. +- Rollback = flag flip. `SELF_SERVE_ENABLED=false` reverts public surfaces; pilot users continue on `complimentary` status (benign — the existing schema supports it either way after step 8). +- New surfaces (pricing page, etc.) return 404 when the flag is off. +- Webhook handler stays live regardless. + +### Risks worth flagging + +| Risk | Mitigation | +|---|---| +| Pilot users confused by "Complimentary Pro" change | Pre-launch email + first-login toast | +| `is_paid` regression — paid metrics include comp accounts pre-fix | Audit `Subscription.is_paid` callers as part of step 1 of implementation; fix in same PR | +| Webhook misfires producing wrong subscription state | Idempotency table + alerting + Stripe webhook replay | +| Multi-head Alembic merge breaks in CI | Test `alembic upgrade heads` (plural) on a fresh DB before merging | +| Stripe Test vs. Live mode confusion | Distinct env vars per env; first prod transaction verified manually | +| OAuth callback `redirect_uri` drift across envs | Single `OAUTH_REDIRECT_BASE` env var; tested per env in validation | +| Email deliverability for verification + invitations + sales leads | Reuse existing `EmailService` pipeline; verify SPF/DKIM/DMARC alignment | +| Email-match enforcement at register breaks teammate accept if invitee mistypes their address | Clear error message; resend with corrected email is one click from the failure page | +| Subscription guard allowlist drift (a new endpoint added without thinking about lockout) | Add a CI test that exercises every router with a `canceled` subscription and verifies 402 unless explicitly allowlisted | +| Email-verification guard allowlist drift (a new endpoint added without thinking about unverified users past grace) | Same CI pattern — exercise every router with an unverified day-8 user and verify 403 unless explicitly allowlisted | +| Plan key rename (`free`/`pro`/`team` → `starter`/`pro`/`enterprise`) | Decision deferred to implementation plan; if rename, migration must update every reference in `subscriptions.plan` and `plan_limits.plan` | + +--- + +## Section 6 — Testing, rollout, open risks + +### Test strategy + +#### Backend (`pytest`) + +- **Unit tests** for `BillingService` methods. Stripe mocked via `respx`. Each method's happy path + at least one error path. +- **Webhook handler integration tests**: feed canned Stripe webhook payloads and assert resulting `subscriptions` state. One test per event type. **Idempotency test**: send the same event id twice, assert single state mutation. +- **`require_feature` integration tests**: parametrized over (plan, flag_key) pairs; test override resolution (`account_feature_overrides` beats `plan_feature_defaults`). +- **`require_active_subscription` integration tests**: + - Each `subscriptions.status` value × allowlisted/non-allowlisted route → expected 200 or 402. + - **Replaces and verifies the trial expiry change**: a `trialing` row with `current_period_end < now()` should NOT be mutated by the dep; the dep should return 402 on protected routes and 200 on allowlisted routes. + - "complimentary should not block protected routes" smoke test. +- **`require_verified_email_after_grace` integration tests**: + - Each combination of (verified, unverified-in-grace, unverified-past-grace) × (allowlisted, non-allowlisted route) → expected 200 or 403. + - OAuth-signup user has `email_verified_at` set at callback time → never blocked. + - User on day 6 unverified passes; user on day 8 unverified blocks; verifying mid-test transitions to passing. +- **Combined-guard test**: protected routers mounting both `require_active_subscription` and `require_verified_email_after_grace` reject an unverified expired-trial account with the appropriate error (whichever check fires first is acceptable; assert one of the two error payloads). +- **Subscription model property tests**: `is_active`, `is_paid`, `has_pro_entitlement` across every status × plan combination. +- **Auth integration tests**: + - `/auth/register` happy path + duplicate email + weak password + email-match enforcement when `account_invite_code` provided. + - `/auth/google/callback` and `/auth/microsoft/callback` with mocked OAuth provider responses. + - `/auth/email/send-verification` auto-fired by register. + - `/auth/email/verify` with valid / expired / already-used tokens (already covered; smoke regression). + - **OAuth-only user paths**: `/auth/login` rejects, `/auth/password/change` rejects, password reset suppressed. +- **Invitation tests**: + - `/accounts/me/invites` create now sends email (regression: today it doesn't). + - `/accounts/me/invites/bulk` creates N rows + sends N emails. + - Email-match enforcement at register. + - Expired/revoked token, idempotent re-accept. +- **Plan-limits + feature-flags admin tests**: existing tests stay; extend with a test that round-trips `plan_billing` fields through `/admin/plan-limits` PUT. +- **Anti-parrot guardrail**: existing `tests/test_prompt_anti_parrot.py` covers any new system prompts (verification email, invitation email, sales-lead intake) automatically. +- **Phase 4 RLS smoke test**: every new account-scoped endpoint exercised with a non-matching `app.current_account_id`. + +#### Frontend (Vitest + Playwright) + +- **Component tests** for `` (each subscription status branch + trialing-expired computed branch), ``, ``, ``, ``, ``, ``. +- **Hook tests** for `useFeature`, `useFeatureLimit`, `useTrialBanner`, `useBillingStore` (initial fetch, refetch on webhook event, refetch after Stripe Checkout success). +- **Playwright E2E**: + - Register → wizard step-by-step → dashboard. + - OAuth round-trip with mocked provider. + - Trial-end wall → plan picker → mock Stripe Checkout → activated state. + - Past-due banner via webhook simulation. + - Pilot complimentary view (no walls, no nudges, "Complimentary Pro" pill). + - Invitation accept (full flow with `account_invite_code` from a backend fixture; email-match success and failure paths). + +#### Manual validation phase (1-2 days before cutover) + +| Scenario | Method | +|---|---| +| Email signup → wizard → first session → trial-end synthetic time → Checkout → active | Real flow with Stripe test mode + a date-shimmed account | +| Google sign-in | Real Google account | +| Microsoft sign-in | Real Microsoft 365 account | +| Past-due simulation | Stripe test card `4000 0000 0000 0341` | +| Pilot complimentary banner + first-login toast | Log in as an existing pilot account post-deploy | +| Webhook signature mismatch handling | Send a forged webhook with bad signature, expect 400 + log entry | +| OAuth provider redirect_uri matches | Visual check on each environment's Google + Microsoft app config | +| `is_paid` audit | Query a known complimentary account: confirm `is_paid=False`, `has_pro_entitlement=True` | + +### Rollout monitoring + +#### PostHog event taxonomy + +- **Funnel**: `pricing_page_viewed`, `register_started`, `register_completed` (with `method`), `email_verification_sent`, `email_verification_completed`. +- **Wizard**: `welcome_wizard_step_completed` (step number), `welcome_wizard_skipped` (`from_step`), `welcome_wizard_completed`. +- **Activation**: `first_session_started` (existing), `psa_connected`, `teammate_invited`, `teammate_accepted_invite`. +- **Trial conversion**: `trial_modal_shown`, `trial_modal_dismissed`, `trial_ended_wall_shown`, `plan_picker_viewed`, `checkout_session_created`, `checkout_completed`, `checkout_abandoned`. +- **Feature-gate signal**: `feature_gate_blocked` (with `feature_key` + `current_plan`). +- **Sales**: `talk_to_sales_form_submitted` (with `source`), `complimentary_account_first_view`. + +#### Alerting + +- Stripe webhook signature failures > 1/hour. +- Stripe API errors during checkout-session creation > 1/hour. +- OAuth callback failures > 5/hour. +- Email send failures (`EmailService` errors) on verification or invitation paths. +- Any 500 from `/webhooks/stripe`. +- 402 rate spike on non-allowlisted endpoints (could indicate guard misconfiguration). + +#### Operational dashboards + +- Daily: trial signups, completed checkouts, MRR delta (using corrected `is_paid`). +- Weekly: trial→paid conversion rate, OAuth-method mix, wizard skip rate per step. +- Per-feature: `feature_gate_blocked` count by `flag_key`. + +### Stripe MCP tooling note + +Once the Stripe MCP plugin loads in a future Claude Code session, it speeds up two things: **debugging webhook state** for support cases and **ad-hoc subscription mutations** (compt'ing accounts, fixing stuck states). Worth using post-launch for ad-hoc support; not load-bearing for the spec. + +### Open risks and unknowns (carry-forward) + +| # | Item | Status | +|---|---|---| +| 1 | **Pricing numbers** ($/seat/month for Starter and Pro) | Out of design scope. Set during validation phase. Schema supports any value via `plan_billing.monthly_price_cents` / `annual_price_cents`. | +| 2 | **Stripe Tax** | Disabled in v1. Revisit when first international signup arrives. | +| 3 | **Multi-account membership** (one user in multiple shops) | Out of scope. v1 is one user → one account. | +| 4 | **Owner transfer** | **Existing capability** — `POST /accounts/me/transfer-ownership` (`accounts.py:150`). Surface in the redesigned Account → Team page. | +| 5 | **Annual billing UI** | Stripe Prices exist via `plan_billing.stripe_annual_price_id`, but the in-app picker only surfaces monthly in v1. Add later. | +| 6 | **SSO (SAML/OIDC) for Enterprise** | Promised on the pricing page Enterprise tier. Actual impl deferred until first paying Enterprise customer. Sales conversation must set expectations honestly. | +| 7 | **GDPR DPA template** | Trust strip claims "GDPR-ready DPA available." Founder/lawyer needs to produce the actual document — not eng work, but blocking the trust-strip claim being honest. | +| 7b | **SOC2 status** | Trust strip claims "SOC2 in progress." If the engagement isn't started by cutover, soften the trust-strip copy. | +| 8 | **Customer Portal cancellation customization** | Stripe-hosted Portal can't be customized. Acceptable for v1. | +| 9 | **Email deliverability** | First big surge may trip spam filters. Verify SPF/DKIM/DMARC alignment before cutover. | +| 10 | **Reverse-trial conversion math** | If trial→paid is bad post-launch, may need to flip to card-upfront. Schema supports it; policy decision based on data. Re-evaluate at week 4. | +| 11 | **Promo codes** | **Deferred from v1.** No `promo_codes` table. If a launch campaign needs them, add a separate table later with Stripe coupon semantics; do not retrofit `invite_codes`. | +| 12 | **Pricing page A/B testing** | Not in v1. PostHog has experiment tooling for A/B headlines later. | +| 13 | **OAuth-only password set-initial flow** | An OAuth-only user can't add a password later in v1. Out of scope; users who want a password can ask support to enable it manually. | +| 14 | **Plan key rename** | Existing `plan_limits` rows use `'free' / 'pro' / 'team'`. Public-facing tiers are Starter / Pro / Enterprise. Implementation plan decides whether to rename keys or maintain a display-name mapping in `plan_billing`. | + +--- + +## Appendix A — Endpoint inventory + +Categorized as **NEW**, **MODIFIED**, or **EXISTING (referenced)**. + +### Public + +| Status | Method | Path | Purpose | +|---|---|---|---| +| NEW (frontend route) | GET | `/pricing` | Public pricing page | +| NEW | POST | `/sales-leads` | Talk-to-sales form | +| NEW | GET/POST | `/auth/google/callback` | Google OAuth callback | +| NEW | GET/POST | `/auth/microsoft/callback` | Microsoft OAuth callback | +| EXISTING | POST | `/auth/email/send-verification` | (auto-called from register; today user-initiated) | +| EXISTING | POST | `/auth/email/verify` | Token consumption | +| MODIFIED | POST | `/auth/register` | Drops invite-code-required gate; calls `BillingService.start_trial()`; auto-sends verification email; **enforces email match against `account_invites.email` when `account_invite_code` is provided** | +| MODIFIED | POST | `/webhooks/stripe` | Stripe webhook handler. Stub exists at `app/api/endpoints/webhooks.py` (signature verification + early-out when `stripe_enabled=False`). This work fleshes out event handlers (`checkout.session.completed`, `customer.subscription.*`, `invoice.payment_*`), idempotency via `stripe_events`, and `BillingService.apply_subscription_event` integration. | + +### Authenticated user + +| Status | Method | Path | Purpose | +|---|---|---|---| +| EXISTING | GET | `/auth/me` | Stays user-focused — no billing data embedded | +| NEW | GET | `/billing/state` | Subscription + plan + plan_limits + resolved feature flags | +| NEW | POST | `/billing/checkout-session` | Create Stripe Checkout session | +| NEW | GET | `/billing/portal-session` | Create Stripe Customer Portal session | +| NEW | GET | `/usage/{flag_or_limit_key}` | Live usage count for quantitative limits | +| NEW | PATCH | `/users/me/onboarding-step` | Persist welcome wizard step state (writes `accounts.name`, `accounts.team_size_bucket`, `accounts.primary_psa`, `users.role_at_signup`) | +| EXISTING | POST | `/accounts/me/transfer-ownership` | Owner transfer (no change) | +| MODIFIED | POST | `/accounts/me/invites` | **Now sends invite email at create-time** (today only resend sends) | +| NEW | POST | `/accounts/me/invites/bulk` | Wraps single-create in a loop; sends email per row | +| EXISTING | POST | `/accounts/me/invites/{id}/resend` | (no change) | +| NEW | DELETE | `/accounts/me/invites/{id}` | Soft-revoke an invite by setting `revoked_at`. (No DELETE/revoke route exists today; only POST create, POST resend, GET list.) | + +### Super-admin (existing — referenced) + +| Status | Method | Path | Purpose | +|---|---|---|---| +| MODIFIED | GET | `/admin/plan-limits` | Response now includes `plan_billing` fields per row | +| MODIFIED | PUT | `/admin/plan-limits` | Accepts `plan_billing` fields in payload (single transaction) | +| EXISTING | GET/POST/PUT/DELETE | `/admin/plan-limits/account-overrides` | (no change) | +| EXISTING | GET/POST/PUT/DELETE | `/admin/feature-flags` | (no change) | +| EXISTING | PUT | `/admin/feature-flags/plan-defaults` | (no change) | +| EXISTING | GET/POST/DELETE | `/admin/feature-flags/account-overrides` | (no change) | + +No new combined `/admin/plans` admin page in v1. + +--- + +## Appendix B — Glossary + +- **Reverse trial**: time-bounded full-access trial with no card required at signup; card requested before billing kicks in. +- **Sales-assist (E)**: dedicated path for Enterprise prospects via "Talk to sales" CTA → contact form → manual onboarding by founder/sales. +- **Wedge**: Escalation Mode — the magic-moment feature pilots are evaluated against (≥1.0 hour saved per week per pilot per kill-switch criteria). +- **Complimentary**: permanent, non-time-bounded `subscriptions.status='complimentary'` value for grandfathered pilot users. No nags, no walls, full Pro entitlement. Distinct from `trialing` in that it never expires; distinct from `active` in that it doesn't count toward paid/MRR metrics. +- **Has Pro entitlement**: a property derived from `(status, plan, current_period_end)` that answers "can this account access Pro features right now?" — true for paid Pro, complimentary Pro, and active trials. Used by `require_feature` and `require_active_subscription`. +- **Locked subscription**: computed state `(status='trialing' AND current_period_end < now())` OR `(status IN ('canceled', 'incomplete'))`. No mutation occurs; `require_active_subscription` raises 402 on protected routes. +- **Plan keys**: `plan_limits.plan` is the canonical key; `plan_billing` joins on it; `subscriptions.plan` is the per-account key. Public-facing tier names (Starter / Pro / Enterprise) are display labels via `plan_billing.display_name`.