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