From 67fae910879b935bbceee4788fef3bc40217aa12 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 23:26:27 -0400 Subject: [PATCH] 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) --- backend/app/api/endpoints/plans_public.py | 58 +++ backend/app/api/router.py | 2 + backend/app/schemas/billing.py | 20 + backend/tests/test_plans_public.py | 132 ++++++ frontend/src/api/index.ts | 2 + frontend/src/api/plans.ts | 22 + frontend/src/pages/PricingPage.tsx | 440 ++++++++++++++++++ .../src/pages/__tests__/PricingPage.test.tsx | 162 +++++++ frontend/src/router.tsx | 6 + 9 files changed, 844 insertions(+) create mode 100644 backend/app/api/endpoints/plans_public.py create mode 100644 backend/tests/test_plans_public.py create mode 100644 frontend/src/api/plans.ts create mode 100644 frontend/src/pages/PricingPage.tsx create mode 100644 frontend/src/pages/__tests__/PricingPage.test.tsx diff --git a/backend/app/api/endpoints/plans_public.py b/backend/app/api/endpoints/plans_public.py new file mode 100644 index 00000000..d5ea4de9 --- /dev/null +++ b/backend/app/api/endpoints/plans_public.py @@ -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 + ] diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 2839d49c..ce587d4f 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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 diff --git a/backend/app/schemas/billing.py b/backend/app/schemas/billing.py index 0a1bcf98..aaae78e6 100644 --- a/backend/app/schemas/billing.py +++ b/backend/app/schemas/billing.py @@ -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} diff --git a/backend/tests/test_plans_public.py b/backend/tests/test_plans_public.py new file mode 100644 index 00000000..a676009a --- /dev/null +++ b/backend/tests/test_plans_public.py @@ -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"] diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1d0ca2f4..64ff005d 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -10,6 +10,8 @@ export { default as stepsApi } from './steps' export { default as stepCategoriesApi } from './stepCategories' export { default as accountsApi } from './accounts' export { default as billingApi } from './billing' +export { default as plansApi } from './plans' +export type { PublicPlanResponse } from './plans' export { default as usageApi } from './usage' export { default as adminApi } from './admin' export { treeMarkdownApi } from './treeMarkdown' diff --git a/frontend/src/api/plans.ts b/frontend/src/api/plans.ts new file mode 100644 index 00000000..17b799af --- /dev/null +++ b/frontend/src/api/plans.ts @@ -0,0 +1,22 @@ +import apiClient from './client' + +export interface PublicPlanResponse { + plan: string + display_name: string + description: string | null + monthly_price_cents: number | null + annual_price_cents: number | null + max_seats: number | null + sort_order: number + is_public: boolean +} + +export const plansApi = { + /** Public plan catalog for the marketing /pricing page. No auth. */ + async getPublic(): Promise { + const response = await apiClient.get('/plans/public') + return response.data + }, +} + +export default plansApi diff --git a/frontend/src/pages/PricingPage.tsx b/frontend/src/pages/PricingPage.tsx new file mode 100644 index 00000000..6d5f4745 --- /dev/null +++ b/frontend/src/pages/PricingPage.tsx @@ -0,0 +1,440 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +import { plansApi, type PublicPlanResponse } from '@/api/plans' +import { PageMeta } from '@/components/common/PageMeta' +import { useAppConfig } from '@/hooks/useAppConfig' +import '@/styles/landing.css' + +/* --------------------------------------------------------------------------- + * v1 hardcoded comparison table + * + * The marketing /pricing page surfaces a small "what's in each plan" table. + * Long-term, the source of truth for "plan X has feature Y" should be a + * server-side feature-flag mapping (likely keyed off feature_flag.display_name + * + plan_features). For v1 we hardcode the well-known features so we can ship + * the page without a backend dependency. Replace this block when a server-side + * feature mapping endpoint exists. + * ------------------------------------------------------------------------- */ +type PlanColumn = 'starter' | 'pro' | 'enterprise' + +const COMPARISON_ROWS: Array<{ + feature: string + values: Record +}> = [ + { feature: 'PSA Integration', values: { starter: false, pro: true, enterprise: true } }, + { feature: 'KB Accelerator', values: { starter: false, pro: true, enterprise: true } }, + { feature: 'AI Builder', values: { starter: true, pro: true, enterprise: true } }, + { feature: 'Custom Branding', values: { starter: false, pro: false, enterprise: true } }, + { feature: 'Priority Support', values: { starter: false, pro: true, enterprise: true } }, +] + +function formatPrice(cents: number | null | undefined): string { + if (cents == null) return '' + const dollars = cents / 100 + // Whole dollars (no decimals) for marketing display. + return `$${Math.round(dollars).toLocaleString()}` +} + +function PricingNotFound() { + return ( +
+

Page not found

+

This page is not available.

+ + Go to login + +
+ ) +} + +interface PlanCardProps { + plan: PublicPlanResponse | null + fallback: { + plan: string + display_name: string + description: string + } + recommended?: boolean + hidePrice?: boolean + ctaLabel: string + ctaHref: string + ctaTestId: string +} + +function PlanCard({ plan, fallback, recommended, hidePrice, ctaLabel, ctaHref, ctaTestId }: PlanCardProps) { + const displayName = plan?.display_name ?? fallback.display_name + const description = plan?.description ?? fallback.description + const monthlyCents = plan?.monthly_price_cents ?? null + + return ( +
+ {recommended && ( + + Recommended + + )} + +
+

+ {displayName} +

+

+ {description} +

+
+ +
+ {hidePrice ? ( +
+ Custom pricing +
+ ) : monthlyCents != null ? ( +
+ + {formatPrice(monthlyCents)} + + / month +
+ ) : ( +
Contact us
+ )} +
+ + + {ctaLabel} + +
+ ) +} + +export function PricingPage() { + const appConfig = useAppConfig() + const [plans, setPlans] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Fetch plans on mount when self-serve is enabled. + useEffect(() => { + if (appConfig.isLoading) return + if (!appConfig.self_serve_enabled) return + + let cancelled = false + setLoading(true) + plansApi + .getPublic() + .then((data) => { + if (cancelled) return + setPlans(data) + setError(null) + }) + .catch(() => { + if (cancelled) return + // Non-fatal: page still renders with fallback descriptions and no + // server-driven prices. The CTA still works via /register?plan=... + setError('Unable to load live pricing.') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [appConfig.isLoading, appConfig.self_serve_enabled]) + + // Self-serve disabled: render a 404-style fallback. Done after hooks so + // the React rules-of-hooks invariant holds. + if (!appConfig.isLoading && !appConfig.self_serve_enabled) { + return ( + <> + + + + ) + } + + const planByName = (name: string) => + plans?.find((p) => p.plan.toLowerCase() === name) ?? null + + return ( +
+ + +
+ {/* ---- HERO ---- */} +
+

+ Simple pricing for MSPs of every size +

+

+ Try Pro free for 14 days. No credit card required. +

+
+ + {/* ---- PLAN CARDS ---- */} +
+ + + +
+ + {loading && ( +
+ Loading pricing… +
+ )} + {error && ( +
+ {error} +
+ )} + + {/* ---- COMPARISON TABLE ---- */} +
+

+ Compare plans +

+
+ + + + + + + + + + + {COMPARISON_ROWS.map((row) => ( + + + + + + + ))} + +
+ Feature + StarterProEnterprise
{row.feature} + {row.values.starter ? '✓' : '—'} + + {row.values.pro ? '✓' : '—'} + + {row.values.enterprise ? '✓' : '—'} +
+
+
+ + {/* ---- TESTIMONIAL SLOT (placeholder) ---- */} +
+
+ "Pilot testimonials coming soon." +
+
+ ResolutionFlow pilot, 2026 +
+
+ + {/* ---- TRUST STRIP ---- */} +
+ Built on Stripe + AWS · Encrypted in transit and at rest +
+
+
+ ) +} + +export default PricingPage diff --git a/frontend/src/pages/__tests__/PricingPage.test.tsx b/frontend/src/pages/__tests__/PricingPage.test.tsx new file mode 100644 index 00000000..e7aa7f76 --- /dev/null +++ b/frontend/src/pages/__tests__/PricingPage.test.tsx @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { HelmetProvider } from 'react-helmet-async' + +import { PricingPage } from '../PricingPage' +import { plansApi, type PublicPlanResponse } from '@/api/plans' +import { + __resetAppConfigCache, + __setAppConfigCache, +} from '@/hooks/useAppConfig' + +vi.mock('@/api/plans', () => ({ + plansApi: { + getPublic: vi.fn(), + }, +})) + +const STARTER: PublicPlanResponse = { + plan: 'starter', + display_name: 'Starter', + description: 'For solo techs.', + monthly_price_cents: 1900, + annual_price_cents: 19000, + max_seats: 3, + sort_order: 10, + is_public: true, +} + +const PRO: PublicPlanResponse = { + plan: 'pro', + display_name: 'Pro', + description: 'For growing MSP teams.', + monthly_price_cents: 4900, + annual_price_cents: 49000, + max_seats: 10, + sort_order: 20, + is_public: true, +} + +const ENTERPRISE: PublicPlanResponse = { + plan: 'enterprise', + display_name: 'Enterprise', + description: 'Custom seats + branding.', + monthly_price_cents: null, + annual_price_cents: null, + max_seats: null, + sort_order: 30, + is_public: true, +} + +function renderPage() { + return render( + + + + + , + ) +} + +describe('PricingPage', () => { + beforeEach(() => { + __resetAppConfigCache() + vi.clearAllMocks() + }) + + it('shows three plan cards with prices from API', async () => { + __setAppConfigCache({ + self_serve_enabled: true, + oauth_providers: [], + }) + vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE]) + + renderPage() + + await waitFor(() => { + expect(plansApi.getPublic).toHaveBeenCalled() + }) + + // Three plan cards present. + expect(screen.getByTestId('plan-card-starter')).toBeInTheDocument() + expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument() + expect(screen.getByTestId('plan-card-enterprise')).toBeInTheDocument() + + // Prices from API rendered. + await waitFor(() => { + expect(screen.getByText('$19')).toBeInTheDocument() + expect(screen.getByText('$49')).toBeInTheDocument() + }) + + // Enterprise card hides price (shows "Custom pricing" instead). + expect(screen.getByText(/Custom pricing/i)).toBeInTheDocument() + + // Pro is recommended. + expect(screen.getByTestId('recommended-badge')).toBeInTheDocument() + }) + + it('Start free trial button navigates to /register?plan=pro', async () => { + __setAppConfigCache({ + self_serve_enabled: true, + oauth_providers: [], + }) + vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE]) + + renderPage() + + const proCta = await screen.findByTestId('cta-pro') + expect(proCta).toHaveAttribute('href', '/register?plan=pro') + expect(proCta).toHaveTextContent(/Start free trial/i) + + const starterCta = screen.getByTestId('cta-starter') + expect(starterCta).toHaveAttribute('href', '/register?plan=starter') + }) + + it('Talk to sales button navigates to /contact-sales', async () => { + __setAppConfigCache({ + self_serve_enabled: true, + oauth_providers: [], + }) + vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE]) + + renderPage() + + const enterpriseCta = await screen.findByTestId('cta-enterprise') + expect(enterpriseCta).toHaveAttribute('href', '/contact-sales') + expect(enterpriseCta).toHaveTextContent(/Talk to sales/i) + }) + + it('returns 404 when self_serve_enabled is false', async () => { + __setAppConfigCache({ + self_serve_enabled: false, + oauth_providers: [], + }) + + renderPage() + + await waitFor(() => { + expect(screen.getByTestId('pricing-not-found')).toBeInTheDocument() + }) + expect(screen.getByText(/Page not found/i)).toBeInTheDocument() + + // No plan cards rendered, no API call made. + expect(screen.queryByTestId('plan-card-starter')).not.toBeInTheDocument() + expect(plansApi.getPublic).not.toHaveBeenCalled() + }) + + it('uses softer trust language (no SOC2/DPA claim yet)', async () => { + __setAppConfigCache({ + self_serve_enabled: true, + oauth_providers: [], + }) + vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE]) + + renderPage() + + const trust = await screen.findByTestId('trust-strip') + expect(trust).toHaveTextContent(/Built on Stripe \+ AWS/i) + expect(trust).toHaveTextContent(/Encrypted in transit and at rest/i) + expect(trust).not.toHaveTextContent(/SOC ?2/i) + }) +}) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 235ef710..2a7db0ca 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -22,6 +22,7 @@ const SurveyPage = lazyWithRetry(() => import('@/pages/SurveyPage')) const SurveyThankYouPage = lazyWithRetry(() => import('@/pages/SurveyThankYouPage')) const PrivacyPage = lazyWithRetry(() => import('@/pages/PrivacyPage')) const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage')) +const PricingPage = lazyWithRetry(() => import('@/pages/PricingPage')) // Standalone auth pages const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage')) @@ -131,6 +132,11 @@ export const router = sentryCreateBrowserRouter([ element: page(TermsPage), errorElement: , }, + { + path: '/pricing', + element: page(PricingPage), + errorElement: , + }, { path: '/login', element: ,