feat(pricing): add /pricing page (B-style)

Phase 2 Task 42: public pricing page gated by SELF_SERVE_ENABLED.

Backend:
- New `GET /api/v1/plans/public` (no auth) returns plan_billing rows
  joined with plan_limits.max_users (as `max_seats`), filtered to
  is_public=true AND is_archived=false, ordered by sort_order ASC,
  plan ASC. Uses get_admin_db (cross-tenant catalog read, same pattern
  as /config/public).
- `PublicPlanResponse` schema in app/schemas/billing.py.
- Registered as PUBLIC in api router.

Frontend:
- `plansApi.getPublic()` client (frontend/src/api/plans.ts).
- `PricingPage` at /pricing with hero / 3 plan cards (Pro recommended,
  Enterprise hides price) / hardcoded v1 comparison table / testimonial
  placeholder / soft trust strip.
- Reads `useAppConfig().self_serve_enabled`; renders a 404 fallback
  when disabled, never calls the API in that path.
- Start free trial CTAs link to /register?plan=starter|pro; Talk to sales
  links to /contact-sales (page wired in Task 43).

Tests:
- Backend: only-public-rows + sort-order ordering.
- Frontend (Vitest): three plan cards with API prices, /register?plan=pro
  CTA, /contact-sales CTA, 404 when self_serve_enabled is false, soft
  trust language (no SOC2 claim).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 23:26:27 -04:00
parent 0c326d0616
commit 67fae91087
9 changed files with 844 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
"""Public plans endpoint — no auth required.
GET /api/v1/plans/public
Returns the public-safe view of `plan_billing` joined with
`plan_limits.max_users` (exposed as `max_seats`), filtered to
`is_public=True AND is_archived=False`, ordered by sort_order ASC, plan ASC.
Distinct from `/admin/plan-limits` (admin-only, returns ALL plans including
archived/internal). This endpoint exists to power the marketing /pricing page
without exposing the rest of the admin-only billing surface.
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.admin_database import get_admin_db
from app.models.plan_billing import PlanBilling
from app.models.plan_limits import PlanLimits
from app.schemas.billing import PublicPlanResponse
router = APIRouter(prefix="/plans", tags=["plans"])
@router.get("/public", response_model=list[PublicPlanResponse])
async def list_public_plans(
db: Annotated[AsyncSession, Depends(get_admin_db)],
) -> list[PublicPlanResponse]:
"""List public, non-archived plans for the marketing /pricing page.
Public — no auth. Uses `get_admin_db` because this is a cross-tenant read
of the global plan catalog (same pattern as `/config/public`).
"""
stmt = (
select(PlanBilling, PlanLimits.max_users)
.outerjoin(PlanLimits, PlanBilling.plan == PlanLimits.plan)
.where(PlanBilling.is_public.is_(True))
.where(PlanBilling.is_archived.is_(False))
.order_by(PlanBilling.sort_order.asc(), PlanBilling.plan.asc())
)
rows = (await db.execute(stmt)).all()
return [
PublicPlanResponse(
plan=billing.plan,
display_name=billing.display_name,
description=billing.description,
monthly_price_cents=billing.monthly_price_cents,
annual_price_cents=billing.annual_price_cents,
max_seats=max_users,
sort_order=billing.sort_order,
is_public=billing.is_public,
)
for billing, max_users in rows
]

View File

@@ -45,6 +45,7 @@ from app.api.endpoints import (
notifications,
oauth as oauth_endpoints,
onboarding,
plans_public,
public_templates,
ratings,
scripts,
@@ -97,6 +98,7 @@ api_router.include_router(public_templates.router) # Public gallery (no auth, r
api_router.include_router(survey.router) # Public survey flow (no auth, rate-limited)
api_router.include_router(config_endpoints.router) # Public runtime feature flags
api_router.include_router(account_invite_lookup.router) # Public invite-code lookup for /accept-invite
api_router.include_router(plans_public.router) # Public plan catalog for /pricing page
# ---------------------------------------------------------------------------
# Admin endpoints — super_admin only

View File

@@ -42,3 +42,23 @@ class BillingStateResponse(BaseModel):
plan_billing: Optional[PlanBillingState]
plan_limits: Dict[str, Any]
enabled_features: Dict[str, bool]
class PublicPlanResponse(BaseModel):
"""Public-safe view of a billable plan, used by the marketing /pricing page.
Sourced from `plan_billing` joined with `plan_limits.max_users` (exposed
here as `max_seats`). Always filtered server-side to is_public=True and
is_archived=False, so `is_public` is a constant True for any row returned
here — included for clarity and forward compatibility.
"""
plan: str
display_name: str
description: Optional[str] = None
monthly_price_cents: Optional[int] = None
annual_price_cents: Optional[int] = None
max_seats: Optional[int] = None
sort_order: int
is_public: bool = True
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,132 @@
"""Integration tests for the public plans endpoint.
Covers GET /api/v1/plans/public — the marketing /pricing page data source.
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import delete
from app.models.plan_billing import PlanBilling
from app.models.plan_limits import PlanLimits
async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None:
"""Ensure a plan_limits row exists for the given plan name."""
existing = await test_db.get(PlanLimits, plan)
if existing is None:
test_db.add(
PlanLimits(
plan=plan,
max_trees=None,
max_sessions_per_month=None,
max_users=max_users,
custom_branding=False,
priority_support=False,
export_formats=["markdown", "text"],
)
)
await test_db.commit()
class TestGetPlansPublic:
"""GET /api/v1/plans/public — anonymous, no auth."""
@pytest.mark.asyncio
async def test_get_plans_public_returns_only_is_public_rows(
self, client: AsyncClient, test_db
):
"""Rows with is_public=False or is_archived=True must NOT appear."""
# Wipe any existing billing rows so this test owns the fixture state.
await test_db.execute(delete(PlanBilling))
await test_db.commit()
await _seed_plan_limits(test_db, "starter", 3)
await _seed_plan_limits(test_db, "pro", 10)
await _seed_plan_limits(test_db, "internal", None)
await _seed_plan_limits(test_db, "legacy", 5)
test_db.add_all(
[
PlanBilling(
plan="starter",
display_name="Starter",
monthly_price_cents=1900,
is_public=True,
is_archived=False,
sort_order=10,
),
PlanBilling(
plan="pro",
display_name="Pro",
monthly_price_cents=4900,
is_public=True,
is_archived=False,
sort_order=20,
),
PlanBilling(
plan="internal",
display_name="Internal",
is_public=False, # hidden
is_archived=False,
sort_order=30,
),
PlanBilling(
plan="legacy",
display_name="Legacy",
is_public=True,
is_archived=True, # archived
sort_order=40,
),
]
)
await test_db.commit()
response = await client.get("/api/v1/plans/public")
assert response.status_code == 200
plans = response.json()
plan_names = {p["plan"] for p in plans}
assert "starter" in plan_names
assert "pro" in plan_names
assert "internal" not in plan_names
assert "legacy" not in plan_names
# Schema sanity check
starter = next(p for p in plans if p["plan"] == "starter")
assert starter["display_name"] == "Starter"
assert starter["monthly_price_cents"] == 1900
assert starter["max_seats"] == 3
assert starter["is_public"] is True
@pytest.mark.asyncio
async def test_get_plans_public_orders_by_sort_order_then_plan(
self, client: AsyncClient, test_db
):
"""Result must be ordered by sort_order ASC, then plan name ASC."""
await test_db.execute(delete(PlanBilling))
await test_db.commit()
# plan_limits rows for FK satisfaction
for name in ("alpha", "bravo", "charlie", "delta"):
await _seed_plan_limits(test_db, name, None)
# Two with sort_order=10 (charlie should come before delta by plan ASC),
# one with sort_order=5 (alpha first overall),
# one with sort_order=20 (bravo last).
test_db.add_all(
[
PlanBilling(plan="charlie", display_name="C", sort_order=10, is_public=True, is_archived=False),
PlanBilling(plan="delta", display_name="D", sort_order=10, is_public=True, is_archived=False),
PlanBilling(plan="alpha", display_name="A", sort_order=5, is_public=True, is_archived=False),
PlanBilling(plan="bravo", display_name="B", sort_order=20, is_public=True, is_archived=False),
]
)
await test_db.commit()
response = await client.get("/api/v1/plans/public")
assert response.status_code == 200
ordered = [p["plan"] for p in response.json()]
assert ordered == ["alpha", "charlie", "delta", "bravo"]