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