Compare commits
14 Commits
0c326d0616
...
feat/self-
| Author | SHA1 | Date | |
|---|---|---|---|
| f85b90c95e | |||
| 5e6541ab92 | |||
| 4a37a47887 | |||
| f31b873459 | |||
| 380fcf7bde | |||
| 4b098deac5 | |||
| 502c0a44e8 | |||
| 06200fabb1 | |||
| 3630dd5a80 | |||
| 5e0c9d2de1 | |||
| fee4cb5b74 | |||
| c75ce0c9a3 | |||
| db2478dd89 | |||
| 67fae91087 |
@@ -1,9 +1,10 @@
|
||||
# CURRENT_TASK.md
|
||||
|
||||
**Active task:** None — pick next from `.ai/TODO.md` or `03-DEVELOPMENT-ROADMAP.md`.
|
||||
**Active task:** Self-serve signup Phase 2 — PR #162 is open on `feat/self-serve-signup-phase-2`. Current focus is resolving its failing Gitea checks. Phase O manual ops (Stripe live setup, internal validation, flag flip) remain pending after review/merge. See `.ai/HANDOFF.md` for the resume point.
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **2026-05-06 — `feat/self-serve-signup-phase-2`** Phase 2 frontend cutover code (Tasks 27–44 of the plan, 18 commits). Backend remainders + frontend billing foundation + auth surfaces (OAuth + accept-invite + verify-email) + welcome wizard + dashboard redesign (TrialPill, NextStepCard, unified checklist) + public surfaces (`/pricing`, `/contact-sales`) + beta-signup deprecation. Phase O (Stripe live setup, internal validation, flag flip) is operational and pending. Single alembic head `c6cbfc534fad` (no new migrations).
|
||||
- **2026-05-02 — PR #159** In-product User Guides rewrite. Merged into `main`. Replaced 15 feature-dump guides with 43 problem-oriented Diátaxis how-tos grouped under 10 categories. Dropped Maintenance Flows / AI Assistant / Flow Assist Sparkles (UI no longer exists). Renamed Step Library → Solutions Library. Authored 14 net-new how-tos for FlowPilot-era surfaces (tasklane keyboard flow, what-we-know, resolve, escalate, record-fix-outcome, post-docs-to-ticket, share-update, pause-and-leave, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate, etc.). Schema additions: `category`, optional `relatedSlugs`; hub renders category sections; detail page renders related-guides footer. Fixed rendering bug where `**bold**` in `step.tip` rendered literally. Killed misleading "N sections" subtitle on guide cards. Browser-verified against engineer + owner login (sidebar labels, account sub-pages, pilot-screen header buttons, Tasks panel, integration form). Two unverified items intentionally deferred: change-teammate-role (requires non-owner test member to inspect role-change control) and detailed Resolve / Escalate modal contents (Resolve gated by 6 pending tasks in test data). tsc and Vite build clean.
|
||||
- **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Merged into `main` as `5e10005`.
|
||||
- **Impeccable pass** (5 sub-passes — distill / quieter / layout / typeset / polish): score 24/40 → 33/40. Removed the duplicate "Suggested checks" chip strip; added an inline `Next steps · N pending in Tasks` cue above the latest action-bearing AI bubble; consolidated the desktop session header to Resolve + Escalate + ⋯ kebab (Context / New Ticket / Update Ticket / Pause now under the kebab, mobile kebab gained Context + New Ticket parity); centered the messages column to `max-w-3xl` to match the composer; bubbles dropped to `rounded-xl`. Decoration sweep: dropped 3px side stripes (TaskLane done states, all 6 ProposalBanner modes, WhatWeKnowItem rows), gradient backgrounds (WhatWeKnow + every banner), accent borderTop on TaskLane header, backdrop-blur on handoff overlay, animate-pulse-amber ring in VerifyingBanner, bordered avatar boxes in banners. Type sweep: 14 distinct sizes → 5-step scale (10/11/12/13/14px). Icon disambiguation: `MessageCircleQuestion` split into `Pencil` (Answer CTA) + `HelpCircle` (per-check explainer). Dead `font-sans` audit (12 sites) and double `text-xs` cleanups.
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-07 — Standardize backend Python on 3.12
|
||||
|
||||
**Context:** Runtime facts had drifted from docs. The backend Dockerfiles and running dev container were already on Python 3.12, GitHub CI had just been updated to 3.12, but project docs still said Python 3.11 and Gitea CI relied on the runner's ambient Python.
|
||||
|
||||
**Decision:** Treat Python 3.12 as the backend standard. Pin local pyenv via `.python-version` to 3.12.13, matching the current `python:3.12-slim` container patch level. Add explicit Python 3.12 setup to Gitea CI and keep GitHub CI on Python 3.12.
|
||||
|
||||
**Rejected:** Moving Docker/runtime back to Python 3.11. The application was already building and running on 3.12, so reverting the runtime would add churn without a product or dependency reason.
|
||||
|
||||
**Consequences:** Native backend work should use `backend/venv` created from Python 3.12.13. Future docs/CI/runtime changes should preserve Python 3.12 unless a deliberate upgrade decision is recorded.
|
||||
|
||||
## 2026-04-30 — Add `applied_pending` non-terminal status to suggested fixes
|
||||
|
||||
**Context:** The verifying banner forces a synchronous verdict — worked / didn't / partial — but a lot of real MSP fixes are async. Engineer ran the script but is waiting on the client to power-cycle, AD replication, an O365 license sync. With only the existing outcomes, the engineer either leaves the banner stale (eroding the verifying signal) or guesses wrong (corrupting outcome data). User flagged the gap directly. Today's `NudgeBanner` "Still checking" button just silences the nudge — it doesn't tell the system anything.
|
||||
|
||||
@@ -2,35 +2,56 @@
|
||||
|
||||
# HANDOFF.md
|
||||
|
||||
**Last updated:** 2026-05-06 (Phase 1 backend complete on `feat/self-serve-signup-spec`)
|
||||
**Last updated:** 2026-05-07 (PR #162 CI investigation/fixes)
|
||||
|
||||
**Active task:** Phase 1 self-serve signup backend foundation — DONE on branch. PR not yet opened.
|
||||
**Active task:** PR #162 (`feat/self-serve-signup-phase-2`) is open in Gitea. Current session is resolving its failing checks.
|
||||
|
||||
## Where this session ended
|
||||
|
||||
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`.
|
||||
PR #162 originally failed quickly in Gitea CI. Public Gitea status metadata was available, but job logs redirected to login and no `GITEA_TOKEN` was present. The branch was pushed over SSH.
|
||||
|
||||
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.
|
||||
Fixed environment drift first:
|
||||
|
||||
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).
|
||||
- Standardized backend native/dev/CI Python on 3.12.13 to match Docker.
|
||||
- Added `.python-version`.
|
||||
- Rebuilt `backend/venv` from pyenv Python 3.12.13 and verified native `pytest --version` / `alembic --version` with explicit local env.
|
||||
- Updated Gitea CI backend/e2e Python setup to 3.12.
|
||||
|
||||
## Resume point — DO THIS NEXT
|
||||
Fixed Gitea runner assumptions next:
|
||||
|
||||
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.
|
||||
- Added `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs.
|
||||
- Pushed `fix(ci): set up node in gitea workflow`.
|
||||
|
||||
## Followups deferred from this session
|
||||
Local frontend validation then exposed real lint failures in Phase 2 React code under the current lint stack. The current WIP fixes:
|
||||
|
||||
- **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.
|
||||
- `react-refresh/only-export-components` for exported pure helpers used by tests/shared invite OAuth code.
|
||||
- `react-hooks/set-state-in-effect` warnings where local state intentionally mirrors route/config/cache state.
|
||||
- `react-hooks/purity` warnings from `Date.now()` during render.
|
||||
- Redundant loading-state write in pricing page.
|
||||
|
||||
## Environment notes (carry-forward)
|
||||
Validation after those frontend changes:
|
||||
|
||||
- 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).
|
||||
- `docker exec -w /app resolutionflow_frontend npm run lint` passed.
|
||||
- `docker exec -w /app resolutionflow_frontend npm run test:coverage` passed (`198` tests).
|
||||
- `docker exec -w /app -e NODE_OPTIONS=--max-old-space-size=4096 resolutionflow_frontend npm run build` passed.
|
||||
|
||||
Known local noise:
|
||||
|
||||
- React `act(...)` warnings appeared in existing tests during coverage but did not fail the suite.
|
||||
- Vite emitted large chunk warnings during build.
|
||||
- Unrelated dirty/untracked files remain and should not be staged unless explicitly requested: `docker-compose.dev.yml`, `.env.example`, `abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`, `core.*`, `docs/architecture/`, `docs/tutorials/`.
|
||||
|
||||
## Resume point
|
||||
|
||||
1. Commit the frontend lint fixes and `.ai/` handoff updates with the required Codex trailer.
|
||||
2. Push `feat/self-serve-signup-phase-2`.
|
||||
3. Poll Gitea PR #162 statuses for the new head SHA:
|
||||
`curl -fsSL https://gitea.resolutionflow.com/api/v1/repos/chihlasm/resolutionflow/statuses/<sha> | python -m json.tool`
|
||||
4. If statuses are still pending, report that local frontend CI is green and Gitea runner work is queued/running. If a check fails, public statuses may show only the context/description; logs require authenticated Gitea access.
|
||||
|
||||
## Carry-forward
|
||||
|
||||
- Phase O manual ops remain pending after PR review/merge: Stripe live setup, internal validation, feature-flag flip.
|
||||
- Backend env: `SALES_LEAD_RECIPIENT_EMAIL`.
|
||||
- Frontend env: `VITE_SELF_SERVE_ENABLED`, `VITE_GOOGLE_CLIENT_ID`, `VITE_MS_CLIENT_ID`, `VITE_OAUTH_REDIRECT_BASE`, `VITE_CALENDLY_URL`.
|
||||
- Single alembic head remains `c6cbfc534fad`; Phase 2 added no migrations.
|
||||
|
||||
@@ -26,7 +26,7 @@ Go-to-Market Validation (pre-PMF). Backend feature-complete (55+ endpoints, 100+
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Backend:** Python 3.11 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
|
||||
- **Backend:** Python 3.12 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
|
||||
- **Frontend:** React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config in `index.css`), Zustand (immer + zundo), React Router v7, Axios (token-refresh interceptor), Lucide.
|
||||
- **DB:** PostgreSQL 16 (RLS enabled Phase 4, pgvector).
|
||||
|
||||
|
||||
@@ -12,6 +12,41 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-07 11:45 EDT — Codex — Push PR #162 CI runner setup fixes
|
||||
|
||||
- Inspected Gitea PR #162 via public API. PR head was `380fcf7` and all CI jobs failed quickly; pushed local commits through `4a37a47`, including Python 3.12 setup for Gitea backend/e2e jobs.
|
||||
- New run on `4a37a47` showed frontend still failed quickly while backend/e2e remained pending. Root cause likely same class of runner drift: Gitea frontend/e2e jobs used `npm` without setting up Node.
|
||||
- Added explicit `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs. This keeps CI from relying on runner ambient Node/npm.
|
||||
- Files touched: `.gitea/workflows/ci.yml`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-07 11:30 EDT — Codex — Standardize backend Python on 3.12
|
||||
|
||||
- Standardized repo declarations around Python 3.12: added `.python-version` pinned to 3.12.13, updated stale Python 3.11 docs, and added explicit Python 3.12 setup steps to Gitea CI. GitHub CI was already updated to Python 3.12 by the user.
|
||||
- Installed pyenv Python 3.12.13 and created `backend/venv` from that interpreter. Installed `backend/requirements-dev.txt` into the venv.
|
||||
- Verified native `python --version` and venv `python --version` both report 3.12.13. Verified native `pytest 8.4.2` and `alembic 1.18.3` with explicit safe test env vars; plain pytest import still depends on local `.env` values being valid.
|
||||
- Rebuilt and restarted the dev backend container with `docker compose -f docker-compose.dev.yml build backend` and `up -d backend`; confirmed `docker exec resolutionflow_backend python --version` reports 3.12.13.
|
||||
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `README.md`, `DEV-ENV.md`, `.ai/PROJECT_CONTEXT.md`, `.ai/DECISIONS.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-07 11:14 EDT — Codex — Recheck native Python availability
|
||||
|
||||
- Re-ran the startup ritual and checked the host Python state after the user reported fixing the missing native Python issue.
|
||||
- Verified `python` and `python3` resolve to `/config/.pyenv/shims/*` and run Python 3.12.10. `pip` and `pip3` are available as pip 25.0.1 under the same pyenv install.
|
||||
- Confirmed there is no native `python3.11`, pyenv currently lists only `3.12.10`, no repo virtualenv exists under `backend/venv`, `backend/.venv`, or root `.venv`, and `python -m pytest --version` from `backend/` fails with `No module named pytest`.
|
||||
- Conclusion: native Python is present, but it is not yet a ready backend dev/test environment for ResolutionFlow. Docker remains the reliable path for pytest/alembic until a Python 3.11 virtualenv with `backend/requirements*.txt` is installed.
|
||||
- Files touched: `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-06 — Claude — Self-serve signup Phase 2 (frontend + cutover code) shipped on `feat/self-serve-signup-phase-2`
|
||||
|
||||
- Executed Tasks 27–44 of `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md` via `superpowers:subagent-driven-development`. 18 commits on `feat/self-serve-signup-phase-2` (off `main` `f918b76`); HEAD `c75ce0c`. Each task: dispatched implementer subagent with full task text + curated context, then spec-compliance + code-quality review subagents; review issues either fixed in-flight via `git commit --amend` or noted as deferred scope.
|
||||
- Backend (Phase I, Tasks 27–31): `BillingService.open_customer_portal` + `GET /billing/portal-session`; `PATCH /users/me/onboarding-step` + dismiss-rest sibling; public `POST /sales-leads` (5/hr/IP); `/admin/plan-limits` GET/PUT round-trips `plan_billing` in one transaction with NOT-NULL guards on `display_name|is_public|is_archived|sort_order`; `BillingService.invalidate_billing_cache` no-op stub; `GET /config/public` (`{self_serve_enabled, oauth_providers}`); `auth/register` invite-code gate now `REQUIRE_INVITE_CODE and not SELF_SERVE_ENABLED and not invite_code`. Also (T36): `GET /accounts/invites/{code}/lookup` (public, joinedload account+inviter); OAuth callback honors `account_invite_code+invited_email`, rejects existing-email user with `email_already_registered_use_login`. Also (T42, T44): `GET /plans/public`; `POST /beta-signup` returns 307 to `${FRONTEND_URL}/register?from=beta`. `OnboardingStatus` extended with `email_verified`+`shop_setup_done`; `UserResponse` exposes `onboarding_step_completed`+`onboarding_dismissed`.
|
||||
- Frontend (Phases J–N, Tasks 32–44): `useBillingStore` Zustand store + `useBillingPoll` mounted in `AppLayout`; `useFeature` / `useFeatureLimit` (60s module cache, lazy `/usage/{field}` fetch with silent fallback — endpoint deferred) / `useTrialBanner` (fractional-day boundary so 24h = warning); `FeatureGate` / `UpgradePrompt` (inline `FEATURE_CATALOG`) / `EmailVerificationGate` (mounted in AppLayout around `<ViewTransitionOutlet />`). `RegisterPage` redesign with OAuth buttons + invite-code conditional; `OAuthCallbackPage` with CSRF state validation + UTF-8-safe base64url state encoding (factored into `lib/oauthState.ts`); `useAppConfig` hook. `AcceptInvitePage` at `/accept-invite` with locked email; `EmailVerificationBanner` refactored to design-system tokens; `EmailVerificationWall` polished; `VerifyEmailPage` at `/verify-email` with single-fire ref guard; `WelcomeRouter` + `WelcomeStep1/2/3` at `/welcome*`; `TrialPill` in topbar (8 stages); `NextStepCard` + `SetupChecklist` (replace orphaned `OnboardingChecklist`); `PricingPage` at `/pricing`; `ContactSalesPage` at `/contact-sales`; `LandingPage` got "See pricing" CTA + replaced beta-signup form with `<Link>`.
|
||||
- Final cross-cutting review caught one real bug — relative `/beta-signup` 307 target landing on API origin instead of frontend — fixed via amend (HEAD `c75ce0c`).
|
||||
- Tests: ~165+ new tests across backend pytest + frontend vitest. Sweep at end-of-branch all-green; tsc -b clean.
|
||||
- Phase O (Tasks 45–47) is explicit manual operations: Stripe live-mode setup, internal validation via `INTERNAL_TESTER_EMAILS` per-email allowlist (backend support for that allowlist is NOT yet built), feature-flag flip + week-1 monitoring. Surfaced as the resume point in HANDOFF.md.
|
||||
- Working tree was dirty before this session (`.ai/HANDOFF.md`, `.env.example`s, `core.*` core dumps, `docs/architecture/`, `docs/tutorials/`); intentionally not staged into Phase 2 commits. Files touched: see `git log --oneline f918b76..HEAD` on `feat/self-serve-signup-phase-2`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-02 ~01:00 UTC — Claude — In-product User Guides Diátaxis rewrite shipped (PR #159)
|
||||
|
||||
- Audited the in-product `/guides` collection against live UI via `/browse` (engineer + owner test users). Existing 15 guides predated the FlowPilot pivot — every "click X in the sidebar" reference was wrong (Dashboard → Home, All Flows → Flows, Sessions → History, Exports gone, etc.). Three guides described surfaces that no longer exist: Maintenance Flows, AI Assistant page, Flow Assist Sparkles button. Findings written to `/tmp/guides-audit.md`.
|
||||
@@ -301,3 +336,13 @@
|
||||
- Files touched: `.ai/*.md` (created), `CLAUDE.md` (rewritten), `AGENTS.md` (created), `SESSION-HANDOFF.md` (deleted).
|
||||
- Follow-up (same day): Codex review pass flagged stale SaaS-role claim and incomplete file-listings carried over from the pre-migration CLAUDE.md. Verified against `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`, `backend/app/api/deps.py`, `backend/app/api/router.py`, and `backend/app/services/psa/`. Corrected PROJECT_CONTEXT.md role hierarchy (`super_admin > owner > engineer > viewer`, not `team_admin`), added `require_account_owner` / `require_team_admin` to deps list, replaced stale endpoint comment with a summary pointing at `api/router.py`, added `exceptions.py` + `ticket_context.py` to the PSA file list. Also replaced seed-example content in `CURRENT_TASK.md` and `TODO.md` with clearer empty-state sentinels.
|
||||
- Branch cleanup (same day): committed pending test-isolation work as `b14a16a chore(tests): gate RLS tests behind RUN_RLS_TESTS flag`, new Phase 9 review doc as `b3506b5 docs(pilot): phase 9 review issues`, and `.remember/` gitignore entry as `b3be1e0 chore: ignore .remember/ skill runtime state`. Deleted `docs/landing-handoff/` (prepared for external design work, not meant to live in the repo). Working tree clean; 3 cleanup commits unpushed.
|
||||
|
||||
## 2026-05-07 UTC — Codex — Resolve PR #162 CI failures
|
||||
|
||||
- Investigated Gitea PR #162 failing checks for `feat/self-serve-signup-phase-2`. Public status metadata was available, but job logs required Gitea login and no token was present.
|
||||
- Standardized backend development/CI Python on 3.12.13 to match the Docker image: added `.python-version`, updated Gitea CI Python setup, rebuilt the local backend virtualenv, and verified native `pytest` / `alembic` command availability with explicit local env.
|
||||
- Added explicit Node 20 setup to Gitea frontend and e2e jobs so CI no longer depends on the runner's ambient Node installation.
|
||||
- Reproduced the remaining frontend failure locally. Lint failed on Phase 2 React code because the current eslint stack flags exported pure helpers, render-time `Date.now()`, and effect-driven state synchronization.
|
||||
- Patched the affected frontend surfaces narrowly: dashboard helper exports, app-config cache handling, feature-limit cache/fetch state, trial-banner time capture, invite/OAuth route error state, pricing loading state, and OAuth authorize URL helper export.
|
||||
- Verified sequential frontend CI locally in Docker: `npm run lint` passed, `npm run test:coverage` passed (`198` tests), and `npm run build` passed with only Vite chunk-size warnings.
|
||||
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `.ai/*`, `README.md`, `DEV-ENV.md`, and the frontend lint-fix files under `frontend/src/components/dashboard`, `frontend/src/hooks`, and `frontend/src/pages`.
|
||||
|
||||
@@ -46,6 +46,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
@@ -105,6 +110,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
@@ -171,6 +181,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -37,10 +37,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python 3.11
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
backend/requirements.txt
|
||||
@@ -143,10 +143,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python 3.11
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
backend/requirements.txt
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12.13
|
||||
@@ -108,7 +108,7 @@ Run these in order. Stop at the first failure and investigate.
|
||||
# Ubuntu / Debian
|
||||
sudo apt update && sudo apt install -y \
|
||||
git curl build-essential \
|
||||
python3.11 python3.11-venv python3-pip \
|
||||
python3.12 python3.12-venv python3-pip \
|
||||
postgresql-client # not the server — only if running Postgres natively
|
||||
|
||||
# Node 20 via nvm (survives container rebuilds if stored in a volume)
|
||||
@@ -236,7 +236,7 @@ REPO_ROOT=/absolute/path/to/resolutionflow
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3.11 -m venv venv
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Prerequisites: Docker, Python 3.11+, Node.js 20+
|
||||
# Prerequisites: Docker, Python 3.12, Node.js 20+
|
||||
|
||||
# Start PostgreSQL
|
||||
docker start patherly_postgres
|
||||
|
||||
@@ -21,4 +21,12 @@ ANTHROPIC_API_KEY=
|
||||
VOYAGE_API_KEY=
|
||||
|
||||
# ConnectWise PSA Integration
|
||||
CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
|
||||
CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
|
||||
|
||||
# Stripe
|
||||
# Test keys from Stripe Dashboard → Developers → API keys (with Test mode toggled on).
|
||||
# Webhook secret for local dev: from `stripe listen --forward-to localhost:8000/api/v1/webhooks/stripe`.
|
||||
# When unset, app/core/config.py:stripe_enabled returns False and Stripe code paths short-circuit.
|
||||
STRIPE_SECRET_KEY=sk_test_
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_
|
||||
STRIPE_WEBHOOK_SECRET=whsec_
|
||||
@@ -47,8 +47,16 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
async def _store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) -> None:
|
||||
"""Decode a refresh token JWT and store its hash in the database."""
|
||||
async def store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) -> None:
|
||||
"""Decode a refresh token JWT and store its hash in the database.
|
||||
|
||||
Module-public so OAuth callback endpoints (and any future token-issuing
|
||||
surface) can register the JTI in the ``refresh_tokens`` table the same
|
||||
way ``/auth/login`` does. Without this the first ``/auth/refresh`` call
|
||||
will reject the token as "revoked" because no row exists.
|
||||
|
||||
Caller is responsible for committing the session.
|
||||
"""
|
||||
payload = decode_token(refresh_token_str)
|
||||
if payload and payload.get("jti"):
|
||||
token_record = RefreshToken(
|
||||
@@ -320,7 +328,7 @@ async def login(
|
||||
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
# Store refresh token hash in DB
|
||||
await _store_refresh_token(db, refresh_token_str, user.id)
|
||||
await store_refresh_token(db, refresh_token_str, user.id)
|
||||
await db.commit()
|
||||
|
||||
return Token(
|
||||
@@ -355,7 +363,7 @@ async def login_json(
|
||||
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
# Store refresh token hash in DB
|
||||
await _store_refresh_token(db, refresh_token_str, user.id)
|
||||
await store_refresh_token(db, refresh_token_str, user.id)
|
||||
await db.commit()
|
||||
|
||||
return Token(
|
||||
@@ -413,7 +421,7 @@ async def refresh_token(
|
||||
new_refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
# Store new refresh token
|
||||
await _store_refresh_token(db, new_refresh_token_str, user.id)
|
||||
await store_refresh_token(db, new_refresh_token_str, user.id)
|
||||
await db.commit()
|
||||
|
||||
return Token(
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
"""Public beta signup endpoint — no auth required."""
|
||||
"""Legacy beta signup endpoint — redirects to /register?from=beta.
|
||||
|
||||
Phase 2 (self-serve signup) makes the public register flow the canonical
|
||||
front door. The old `/api/v1/beta-signup` POST endpoint is kept mounted to
|
||||
preserve any external links that still hit it, but now responds with a
|
||||
307 Temporary Redirect to `/register?from=beta` so the user lands in the
|
||||
real signup flow. The `?from=beta` marker lets the frontend tag the
|
||||
signup origin for analytics.
|
||||
|
||||
Note: there is no `beta_signup` database table — the original endpoint
|
||||
only fired a notification email. There is therefore no waitlist to email
|
||||
and no migration to run when retiring the endpoint.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from app.core.email import EmailService
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/beta-signup", tags=["beta"])
|
||||
|
||||
|
||||
class BetaSignupRequest(BaseModel):
|
||||
email: EmailStr
|
||||
# Local-dev fallback when FRONTEND_URL isn't configured. The redirect must
|
||||
# be absolute — a relative URL would resolve against the API origin
|
||||
# (api.resolutionflow.com), which has no /register page.
|
||||
_DEFAULT_FRONTEND_URL = "http://localhost:5173"
|
||||
|
||||
|
||||
class BetaSignupResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
@router.post("", include_in_schema=False)
|
||||
async def beta_signup_redirect() -> RedirectResponse:
|
||||
"""Redirect legacy beta-signup POST to the public register page.
|
||||
|
||||
|
||||
@router.post("", response_model=BetaSignupResponse)
|
||||
async def beta_signup(data: BetaSignupRequest):
|
||||
"""Collect beta interest — sends notification to beta@resolutionflow.com."""
|
||||
sent = await EmailService.send_beta_signup_notification(data.email)
|
||||
if not sent:
|
||||
logger.warning("Beta signup recorded (email delivery skipped): %s", data.email)
|
||||
return BetaSignupResponse(
|
||||
success=True,
|
||||
message="Thanks! We'll be in touch with beta access details.",
|
||||
Returns 307 so any client following the redirect preserves the HTTP
|
||||
method; the frontend treats `/register?from=beta` as the canonical
|
||||
entry point and reads the `from` query param for analytics.
|
||||
"""
|
||||
frontend_url = settings.FRONTEND_URL or _DEFAULT_FRONTEND_URL
|
||||
return RedirectResponse(
|
||||
url=f"{frontend_url}/register?from=beta",
|
||||
status_code=307,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.endpoints.auth import store_refresh_token
|
||||
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
|
||||
@@ -186,9 +187,16 @@ async def google_callback(
|
||||
account_invite_code=payload.account_invite_code,
|
||||
invited_email=payload.invited_email,
|
||||
)
|
||||
refresh_token_str = create_refresh_token({"sub": str(user.id)})
|
||||
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
|
||||
# reject this token as "revoked" (the rotation logic requires a row to
|
||||
# mark as used). _sign_in_or_register already committed; this needs a
|
||||
# second commit.
|
||||
await store_refresh_token(db, refresh_token_str, user.id)
|
||||
await db.commit()
|
||||
return OAuthCallbackResponse(
|
||||
access_token=create_access_token({"sub": str(user.id)}),
|
||||
refresh_token=create_refresh_token({"sub": str(user.id)}),
|
||||
refresh_token=refresh_token_str,
|
||||
is_new_user=is_new,
|
||||
)
|
||||
|
||||
@@ -209,8 +217,15 @@ async def microsoft_callback(
|
||||
account_invite_code=payload.account_invite_code,
|
||||
invited_email=payload.invited_email,
|
||||
)
|
||||
refresh_token_str = create_refresh_token({"sub": str(user.id)})
|
||||
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
|
||||
# reject this token as "revoked" (the rotation logic requires a row to
|
||||
# mark as used). _sign_in_or_register already committed; this needs a
|
||||
# second commit.
|
||||
await store_refresh_token(db, refresh_token_str, user.id)
|
||||
await db.commit()
|
||||
return OAuthCallbackResponse(
|
||||
access_token=create_access_token({"sub": str(user.id)}),
|
||||
refresh_token=create_refresh_token({"sub": str(user.id)}),
|
||||
refresh_token=refresh_token_str,
|
||||
is_new_user=is_new,
|
||||
)
|
||||
|
||||
58
backend/app/api/endpoints/plans_public.py
Normal file
58
backend/app/api/endpoints/plans_public.py
Normal 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
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -210,28 +210,44 @@ class BillingService:
|
||||
) -> 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."""
|
||||
either way.
|
||||
|
||||
Atomic: the StripeEvent idempotency mark and the handler's state
|
||||
mutations are committed in a single transaction. If the handler raises
|
||||
the entire transaction (idempotency mark + partial mutations) is rolled
|
||||
back, so a Stripe retry will re-run the handler. Without this, a
|
||||
handler that fails mid-flight would leave the StripeEvent row persisted
|
||||
and silently desync subscription state from Stripe.
|
||||
"""
|
||||
db.add(StripeEvent(
|
||||
id=event_id,
|
||||
event_type=event_type,
|
||||
payload_excerpt=_excerpt(payload),
|
||||
))
|
||||
try:
|
||||
db.add(StripeEvent(
|
||||
id=event_id,
|
||||
event_type=event_type,
|
||||
payload_excerpt=_excerpt(payload),
|
||||
))
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
# Duplicate event_id — already processed (or in flight). Ack with False.
|
||||
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)
|
||||
try:
|
||||
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)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
# Roll back the StripeEvent insert + any partial handler mutations
|
||||
# so Stripe's retry can re-run cleanly.
|
||||
await db.rollback()
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
@@ -282,7 +298,7 @@ async def _handle_checkout_completed(db: AsyncSession, payload: dict):
|
||||
)).scalar_one_or_none()
|
||||
if pb is not None:
|
||||
sub.plan = pb.plan
|
||||
await db.commit()
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
|
||||
|
||||
async def _handle_subscription_updated(db: AsyncSession, payload: dict):
|
||||
@@ -297,7 +313,7 @@ async def _handle_subscription_updated(db: AsyncSession, payload: dict):
|
||||
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()
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
|
||||
|
||||
async def _handle_subscription_deleted(db: AsyncSession, payload: dict):
|
||||
@@ -308,7 +324,7 @@ async def _handle_subscription_deleted(db: AsyncSession, payload: dict):
|
||||
if sub is None:
|
||||
return
|
||||
sub.status = "canceled"
|
||||
await db.commit()
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
|
||||
|
||||
async def _handle_payment_failed(db: AsyncSession, payload: dict):
|
||||
@@ -322,7 +338,7 @@ async def _handle_payment_failed(db: AsyncSession, payload: dict):
|
||||
if sub is None:
|
||||
return
|
||||
sub.status = "past_due"
|
||||
await db.commit()
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
|
||||
|
||||
async def _handle_payment_succeeded(db: AsyncSession, payload: dict):
|
||||
@@ -337,4 +353,4 @@ async def _handle_payment_succeeded(db: AsyncSession, payload: dict):
|
||||
return
|
||||
if sub.status == "past_due":
|
||||
sub.status = "active"
|
||||
await db.commit()
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
|
||||
43
backend/tests/test_beta_signup_redirect.py
Normal file
43
backend/tests/test_beta_signup_redirect.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Integration tests for the legacy /beta-signup redirect.
|
||||
|
||||
Phase 2 retires the public beta-signup form in favor of the regular
|
||||
register flow. The endpoint stays mounted but answers with a 307 to
|
||||
the absolute frontend `/register?from=beta` URL so any external links
|
||||
keep working. There is no `beta_signup` table to migrate — the old
|
||||
endpoint only fired an email notification — so this test only covers
|
||||
the redirect contract.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_beta_signup_redirects_to_register(client, monkeypatch):
|
||||
"""POST /beta-signup returns 307 to the absolute frontend register URL."""
|
||||
monkeypatch.setattr(settings, "FRONTEND_URL", "https://example.com")
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/beta-signup",
|
||||
json={"email": "anyone@example.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 307, response.text
|
||||
assert (
|
||||
response.headers["location"]
|
||||
== "https://example.com/register?from=beta"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_beta_signup_redirect_ignores_body(client, monkeypatch):
|
||||
"""Redirect fires regardless of payload — no validation on the legacy route."""
|
||||
monkeypatch.setattr(settings, "FRONTEND_URL", "https://example.com")
|
||||
|
||||
response = await client.post("/api/v1/beta-signup", json={})
|
||||
assert response.status_code == 307
|
||||
assert (
|
||||
response.headers["location"]
|
||||
== "https://example.com/register?from=beta"
|
||||
)
|
||||
@@ -2,8 +2,10 @@ import uuid
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from sqlalchemy import select
|
||||
from app.core.security import decode_token, hash_token
|
||||
from app.models.user import User
|
||||
from app.models.oauth_identity import OAuthIdentity
|
||||
from app.models.refresh_token import RefreshToken
|
||||
from app.models.subscription import Subscription
|
||||
from app.services.oauth_providers import OAuthProfile
|
||||
|
||||
@@ -118,3 +120,77 @@ async def test_microsoft_callback_creates_user(client, test_db, monkeypatch):
|
||||
select(OAuthIdentity).where(OAuthIdentity.user_id == user.id)
|
||||
)).scalar_one()
|
||||
assert identity.provider == "microsoft"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_google_callback_stores_refresh_token_jti(
|
||||
client, test_db, monkeypatch
|
||||
):
|
||||
"""A successful Google OAuth callback must persist the refresh-token JTI
|
||||
in the refresh_tokens table — otherwise /auth/refresh rejects it."""
|
||||
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_jti_test",
|
||||
email="jtitest@example.com",
|
||||
name="JTI Test",
|
||||
)
|
||||
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()
|
||||
refresh_token_str = body["refresh_token"]
|
||||
|
||||
payload = decode_token(refresh_token_str)
|
||||
assert payload is not None
|
||||
jti = payload["jti"]
|
||||
token_hash = hash_token(jti)
|
||||
|
||||
user = (await test_db.execute(
|
||||
select(User).where(User.email == "jtitest@example.com")
|
||||
)).scalar_one()
|
||||
|
||||
stored = (await test_db.execute(
|
||||
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||||
)).scalar_one_or_none()
|
||||
assert stored is not None, "OAuth callback did not persist refresh-token JTI"
|
||||
assert stored.user_id == user.id
|
||||
assert stored.revoked_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_refresh_works_after_oauth_signup(
|
||||
client, test_db, monkeypatch
|
||||
):
|
||||
"""End-to-end: OAuth callback issues a refresh token; calling /auth/refresh
|
||||
with that token must succeed (not be rejected as revoked)."""
|
||||
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_refresh_test",
|
||||
email="refresh-after-oauth@example.com",
|
||||
name="Refresh After OAuth",
|
||||
)
|
||||
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
|
||||
callback_resp = await client.post(
|
||||
"/api/v1/auth/google/callback", json={"code": "auth_code_xyz"}
|
||||
)
|
||||
assert callback_resp.status_code == 200, callback_resp.json()
|
||||
refresh_token_str = callback_resp.json()["refresh_token"]
|
||||
|
||||
refresh_resp = await client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
headers={"Authorization": f"Bearer {refresh_token_str}"},
|
||||
)
|
||||
assert refresh_resp.status_code == 200, refresh_resp.json()
|
||||
refreshed = refresh_resp.json()
|
||||
assert refreshed["access_token"]
|
||||
assert refreshed["refresh_token"]
|
||||
# Token rotation: new refresh token differs from the original.
|
||||
assert refreshed["refresh_token"] != refresh_token_str
|
||||
|
||||
132
backend/tests/test_plans_public.py
Normal file
132
backend/tests/test_plans_public.py
Normal 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"]
|
||||
@@ -142,3 +142,178 @@ async def test_webhook_idempotency(
|
||||
assert r2.status_code == 200
|
||||
assert r1.json()["applied"] is True
|
||||
assert r2.json()["applied"] is False
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Atomic-idempotency regression tests
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_event_handler_failure_does_not_persist_idempotency_mark(
|
||||
test_db, test_user,
|
||||
):
|
||||
"""If the handler raises, the StripeEvent row must NOT be persisted —
|
||||
otherwise Stripe's retry will be silently dropped as a duplicate and the
|
||||
subscription state will desync from Stripe."""
|
||||
from app.services.billing import BillingService
|
||||
from app.models.stripe_event import StripeEvent
|
||||
|
||||
event_id = "evt_handler_fail_1"
|
||||
payload = {"data": {"object": {
|
||||
"id": "sub_doesnotmatter",
|
||||
"status": "active",
|
||||
"current_period_start": 1714521600,
|
||||
"current_period_end": 1717113600,
|
||||
"items": {"data": [{"quantity": 1}]},
|
||||
"cancel_at_period_end": False,
|
||||
}}}
|
||||
|
||||
boom = RuntimeError("simulated handler failure")
|
||||
with patch(
|
||||
"app.services.billing._handle_subscription_updated",
|
||||
side_effect=boom,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="simulated handler failure"):
|
||||
await BillingService.apply_subscription_event(
|
||||
test_db,
|
||||
event_id=event_id,
|
||||
event_type="customer.subscription.updated",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
# The StripeEvent row must not exist — handler raised, the entire
|
||||
# transaction (idempotency mark + partial mutations) was rolled back.
|
||||
row = (await test_db.execute(
|
||||
select(StripeEvent).where(StripeEvent.id == event_id)
|
||||
)).scalar_one_or_none()
|
||||
assert row is None, (
|
||||
"StripeEvent row was persisted despite handler failure — "
|
||||
"Stripe retry will be silently dropped"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_event_retry_after_failure_succeeds(
|
||||
test_db, test_user,
|
||||
):
|
||||
"""A failed first attempt followed by a successful retry must apply state.
|
||||
This is the core Stripe webhook retry contract."""
|
||||
from app.services.billing import BillingService
|
||||
from app.models.stripe_event import StripeEvent
|
||||
|
||||
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",
|
||||
stripe_subscription_id="sub_retry",
|
||||
))
|
||||
await test_db.commit()
|
||||
|
||||
event_id = "evt_retry_1"
|
||||
payload = {"data": {"object": {
|
||||
"id": "sub_retry",
|
||||
"status": "active",
|
||||
"current_period_start": 1714521600,
|
||||
"current_period_end": 1717113600,
|
||||
"items": {"data": [{"quantity": 3}]},
|
||||
"cancel_at_period_end": False,
|
||||
}}}
|
||||
|
||||
# First attempt — handler raises mid-flight.
|
||||
with patch(
|
||||
"app.services.billing._handle_subscription_updated",
|
||||
side_effect=RuntimeError("transient blip"),
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
await BillingService.apply_subscription_event(
|
||||
test_db,
|
||||
event_id=event_id,
|
||||
event_type="customer.subscription.updated",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
# No idempotency mark, sub still trialing.
|
||||
row = (await test_db.execute(
|
||||
select(StripeEvent).where(StripeEvent.id == event_id)
|
||||
)).scalar_one_or_none()
|
||||
assert row is None
|
||||
sub = (await test_db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account_id)
|
||||
)).scalar_one()
|
||||
assert sub.status == "trialing"
|
||||
|
||||
# Second attempt — same event_id, handler succeeds.
|
||||
applied = await BillingService.apply_subscription_event(
|
||||
test_db,
|
||||
event_id=event_id,
|
||||
event_type="customer.subscription.updated",
|
||||
payload=payload,
|
||||
)
|
||||
assert applied is True
|
||||
|
||||
# Idempotency mark now persisted, sub state reconciled.
|
||||
row = (await test_db.execute(
|
||||
select(StripeEvent).where(StripeEvent.id == event_id)
|
||||
)).scalar_one()
|
||||
assert row.id == event_id
|
||||
await test_db.refresh(sub)
|
||||
assert sub.status == "active"
|
||||
assert sub.seat_limit == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_event_duplicate_event_id_skips(
|
||||
test_db, test_user,
|
||||
):
|
||||
"""Two successful invocations with the same event_id must not double-apply.
|
||||
Second call returns False; mutations are not repeated."""
|
||||
from app.services.billing import BillingService
|
||||
|
||||
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",
|
||||
stripe_subscription_id="sub_dup",
|
||||
))
|
||||
await test_db.commit()
|
||||
|
||||
event_id = "evt_dedupe_1"
|
||||
payload = {"data": {"object": {
|
||||
"id": "sub_dup",
|
||||
"status": "active",
|
||||
"current_period_start": 1714521600,
|
||||
"current_period_end": 1717113600,
|
||||
"items": {"data": [{"quantity": 7}]},
|
||||
"cancel_at_period_end": False,
|
||||
}}}
|
||||
|
||||
applied1 = await BillingService.apply_subscription_event(
|
||||
test_db,
|
||||
event_id=event_id,
|
||||
event_type="customer.subscription.updated",
|
||||
payload=payload,
|
||||
)
|
||||
assert applied1 is True
|
||||
|
||||
sub = (await test_db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account_id)
|
||||
)).scalar_one()
|
||||
assert sub.status == "active"
|
||||
assert sub.seat_limit == 7
|
||||
|
||||
# Mutate locally so we can prove the second call doesn't re-run the handler.
|
||||
sub.seat_limit = 99
|
||||
await test_db.commit()
|
||||
|
||||
applied2 = await BillingService.apply_subscription_event(
|
||||
test_db,
|
||||
event_id=event_id,
|
||||
event_type="customer.subscription.updated",
|
||||
payload=payload,
|
||||
)
|
||||
assert applied2 is False
|
||||
|
||||
await test_db.refresh(sub)
|
||||
# Handler did NOT run again — our local mutation is preserved.
|
||||
assert sub.seat_limit == 99
|
||||
|
||||
@@ -21,3 +21,8 @@ VITE_OAUTH_REDIRECT_BASE=
|
||||
# Self-serve signup safety fallback used by useAppConfig when GET /config/public
|
||||
# is unreachable. Authoritative value comes from backend SELF_SERVE_ENABLED.
|
||||
VITE_SELF_SERVE_ENABLED=false
|
||||
|
||||
# Calendly link surfaced on the /contact-sales confirmation screen. When unset,
|
||||
# the "Want to skip ahead?" block is hidden. Vite bakes at build time, so prod
|
||||
# requires ARG+ENV in frontend/Dockerfile.
|
||||
VITE_CALENDLY_URL=
|
||||
|
||||
@@ -22,6 +22,7 @@ ARG VITE_GOOGLE_CLIENT_ID
|
||||
ARG VITE_MS_CLIENT_ID
|
||||
ARG VITE_OAUTH_REDIRECT_BASE
|
||||
ARG VITE_SELF_SERVE_ENABLED
|
||||
ARG VITE_CALENDLY_URL
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
|
||||
ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY
|
||||
@@ -31,6 +32,7 @@ ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
||||
ENV VITE_MS_CLIENT_ID=$VITE_MS_CLIENT_ID
|
||||
ENV VITE_OAUTH_REDIRECT_BASE=$VITE_OAUTH_REDIRECT_BASE
|
||||
ENV VITE_SELF_SERVE_ENABLED=$VITE_SELF_SERVE_ENABLED
|
||||
ENV VITE_CALENDLY_URL=$VITE_CALENDLY_URL
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { AxiosError } from 'axios'
|
||||
|
||||
import apiClient from './client'
|
||||
import type { BillingStateApiResponse, BillingStatePayload } from '@/types'
|
||||
import {
|
||||
BillingPortalError,
|
||||
type BillingPortalErrorCode,
|
||||
type BillingPortalSessionResponse,
|
||||
type BillingStateApiResponse,
|
||||
type BillingStatePayload,
|
||||
type CheckoutSessionRequest,
|
||||
type CheckoutSessionResponse,
|
||||
} from '@/types/billing'
|
||||
|
||||
/**
|
||||
* Single boundary where the snake_case backend payload is transformed
|
||||
@@ -22,6 +32,48 @@ export const billingApi = {
|
||||
const response = await apiClient.get<BillingStateApiResponse>('/billing/state')
|
||||
return transformBillingState(response.data)
|
||||
},
|
||||
|
||||
/**
|
||||
* Request a Stripe Customer Portal session URL for the active account.
|
||||
*
|
||||
* Throws a typed `BillingPortalError` when:
|
||||
* - HTTP 503 → `stripe_not_configured` (server-side Stripe is disabled)
|
||||
* - HTTP 400 + `error: 'no_stripe_customer'` → account hasn't been billed yet
|
||||
*
|
||||
* Other errors (5xx, network) propagate as the underlying AxiosError.
|
||||
*/
|
||||
async getPortalSession(): Promise<BillingPortalSessionResponse> {
|
||||
try {
|
||||
const response = await apiClient.get<BillingPortalSessionResponse>(
|
||||
'/billing/portal-session',
|
||||
)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response) {
|
||||
const { status, data } = err.response
|
||||
const code: BillingPortalErrorCode | null =
|
||||
status === 503
|
||||
? 'stripe_not_configured'
|
||||
: status === 400 && data?.detail?.error === 'no_stripe_customer'
|
||||
? 'no_stripe_customer'
|
||||
: null
|
||||
if (code) {
|
||||
throw new BillingPortalError(code)
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async createCheckoutSession(
|
||||
payload: CheckoutSessionRequest,
|
||||
): Promise<CheckoutSessionResponse> {
|
||||
const response = await apiClient.post<CheckoutSessionResponse>(
|
||||
'/billing/checkout-session',
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default billingApi
|
||||
|
||||
@@ -10,6 +10,14 @@ 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 salesApi } from './sales'
|
||||
export type {
|
||||
SalesLeadCreatePayload,
|
||||
SalesLeadCreateResponse,
|
||||
SalesLeadSource,
|
||||
} from './sales'
|
||||
export { default as usageApi } from './usage'
|
||||
export { default as adminApi } from './admin'
|
||||
export { treeMarkdownApi } from './treeMarkdown'
|
||||
|
||||
22
frontend/src/api/plans.ts
Normal file
22
frontend/src/api/plans.ts
Normal file
@@ -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<PublicPlanResponse[]> {
|
||||
const response = await apiClient.get<PublicPlanResponse[]>('/plans/public')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default plansApi
|
||||
32
frontend/src/api/sales.ts
Normal file
32
frontend/src/api/sales.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export type SalesLeadSource = 'pricing_page' | 'register_footer' | 'landing_page'
|
||||
|
||||
export interface SalesLeadCreatePayload {
|
||||
email: string
|
||||
name: string
|
||||
company: string
|
||||
team_size?: string
|
||||
message?: string
|
||||
source: SalesLeadSource
|
||||
posthog_distinct_id?: string
|
||||
}
|
||||
|
||||
export interface SalesLeadCreateResponse {
|
||||
id: string
|
||||
status: 'received'
|
||||
}
|
||||
|
||||
export const salesApi = {
|
||||
/**
|
||||
* Public Talk-to-Sales submission. No auth required. Rate-limited per IP
|
||||
* server-side (5/hour). Server emits PostHog `talk_to_sales_form_submitted`
|
||||
* — frontend should NOT also fire this event.
|
||||
*/
|
||||
async createLead(payload: SalesLeadCreatePayload): Promise<SalesLeadCreateResponse> {
|
||||
const response = await apiClient.post<SalesLeadCreateResponse>('/sales-leads', payload)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default salesApi
|
||||
@@ -49,6 +49,7 @@ const PLAN_GATE_STAGES: ReadonlyArray<TrialBannerStage> = [
|
||||
* Pure helper — picks the highest-priority incomplete item, or `null` when
|
||||
* all relevant items are done. Exported for direct unit testing.
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- pure helper exported for focused unit tests
|
||||
export function pickNextStep(
|
||||
status: OnboardingStatus,
|
||||
trialStage: TrialBannerStage | null,
|
||||
|
||||
@@ -29,6 +29,7 @@ const PLAN_GATE_STAGES: ReadonlyArray<TrialBannerStage> = [
|
||||
'expired',
|
||||
]
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- pure helper exported for focused unit tests
|
||||
export function buildChecklistItems(
|
||||
status: OnboardingStatus,
|
||||
trialStage: TrialBannerStage | null,
|
||||
|
||||
@@ -66,10 +66,7 @@ export function useAppConfig(): UseAppConfigResult {
|
||||
const [config, setConfig] = useState<PublicConfig | null>(cached)
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) {
|
||||
setConfig(cached)
|
||||
return
|
||||
}
|
||||
if (cached) return
|
||||
let active = true
|
||||
const handler = (c: PublicConfig) => {
|
||||
if (active) setConfig(c)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
import { usageApi } from '@/api/usage'
|
||||
|
||||
@@ -53,61 +53,38 @@ function coerceLimit(raw: unknown): number | null {
|
||||
export function useFeatureLimit(field: string): FeatureLimitResult {
|
||||
const limit = useBillingStore((state) => coerceLimit(state.planLimits[field]))
|
||||
|
||||
// Initialize from cache on first mount only; subsequent `field` changes
|
||||
// are handled inside the effect below so the render-phase result reflects
|
||||
// the new field synchronously (no stale `used`/`isLoading` for one tick).
|
||||
const initialCached = useRef<CacheEntry | undefined>(undefined)
|
||||
if (initialCached.current === undefined) {
|
||||
initialCached.current = cache.get(field)
|
||||
}
|
||||
const initialFresh =
|
||||
initialCached.current && Date.now() - initialCached.current.timestamp < CACHE_TTL_MS
|
||||
const [used, setUsed] = useState<number>(initialFresh ? initialCached.current!.used : 0)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(!initialFresh)
|
||||
|
||||
// Track the field that the current `used`/`isLoading` state describes.
|
||||
// When `field` changes, we synchronously reset state in render so callers
|
||||
// never see stale data for the previous field.
|
||||
const stateField = useRef<string>(field)
|
||||
if (stateField.current !== field) {
|
||||
stateField.current = field
|
||||
const [state, setState] = useState(() => {
|
||||
const existing = cache.get(field)
|
||||
const freshNow = existing && Date.now() - existing.timestamp < CACHE_TTL_MS
|
||||
if (freshNow) {
|
||||
setUsed(existing!.used)
|
||||
setIsLoading(false)
|
||||
} else {
|
||||
setUsed(0)
|
||||
setIsLoading(true)
|
||||
const fresh = existing && Date.now() - existing.timestamp < CACHE_TTL_MS
|
||||
return {
|
||||
field,
|
||||
used: fresh ? existing.used : 0,
|
||||
isLoading: !fresh,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const existing = cache.get(field)
|
||||
if (existing && Date.now() - existing.timestamp < CACHE_TTL_MS) {
|
||||
setUsed(existing.used)
|
||||
setIsLoading(false)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync local hook state with fresh module cache on field change
|
||||
setState({ field, used: existing.used, isLoading: false })
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setIsLoading(true)
|
||||
setState({ field, used: 0, isLoading: true })
|
||||
usageApi
|
||||
.getCount(field)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
cache.set(field, { used: result.used, timestamp: Date.now() })
|
||||
setUsed(result.used)
|
||||
setState({ field, used: result.used, isLoading: false })
|
||||
})
|
||||
.catch(() => {
|
||||
// TODO: backend /usage/{field} endpoint not yet implemented (planned).
|
||||
// 404s and other errors degrade to used=0 silently — no toast.
|
||||
if (cancelled) return
|
||||
setUsed(0)
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return
|
||||
setIsLoading(false)
|
||||
setState({ field, used: 0, isLoading: false })
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -115,6 +92,8 @@ export function useFeatureLimit(field: string): FeatureLimitResult {
|
||||
}
|
||||
}, [field])
|
||||
|
||||
const used = state.field === field ? state.used : 0
|
||||
const isLoading = state.field === field ? state.isLoading : true
|
||||
const percentage =
|
||||
limit === null || limit <= 0 ? null : Math.min(100, Math.round((used / limit) * 100))
|
||||
const isAtLimit = limit !== null && used >= limit
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
|
||||
export type TrialBannerStage =
|
||||
@@ -28,6 +29,7 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
*/
|
||||
export function useTrialBanner(): TrialBannerResult {
|
||||
const subscription = useBillingStore((state) => state.subscription)
|
||||
const [now] = useState(() => Date.now())
|
||||
|
||||
if (!subscription) {
|
||||
return { stage: null, daysRemaining: null }
|
||||
@@ -51,7 +53,6 @@ export function useTrialBanner(): TrialBannerResult {
|
||||
// upgrade prompt still surfaces rather than silently swallowing it.
|
||||
return { stage: 'expired', daysRemaining: null }
|
||||
}
|
||||
const now = Date.now()
|
||||
if (end <= now) {
|
||||
return { stage: 'expired', daysRemaining: 0 }
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export function AcceptInvitePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- route changes without a code should replace stale lookup state
|
||||
setLookup({ status: 'missing-code' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Crown,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
@@ -598,6 +599,12 @@ export function AccountSettingsPage() {
|
||||
title="Profile"
|
||||
description="Your name, email, and personal preferences"
|
||||
/>
|
||||
<SettingsRow
|
||||
to="/account/billing"
|
||||
icon={<CreditCard className="h-4 w-4" />}
|
||||
title="Billing"
|
||||
description="Subscription, payment method, and invoices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAccountOwner && (
|
||||
|
||||
396
frontend/src/pages/ContactSalesPage.tsx
Normal file
396
frontend/src/pages/ContactSalesPage.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import { useMemo, useState, type FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { salesApi, type SalesLeadSource } from '@/api/sales'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||
import '@/styles/landing.css'
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Source detection
|
||||
*
|
||||
* The backend `/sales-leads` endpoint requires a `source` enum. We classify
|
||||
* by document.referrer: visitors landing on /contact-sales after viewing the
|
||||
* pricing page get tagged `pricing_page`; everything else is `landing_page`.
|
||||
* `register_footer` is reserved for the (future) sign-up footer CTA.
|
||||
*
|
||||
* Acceptable for v1 — server-side PostHog event uses this same `source` value.
|
||||
* ------------------------------------------------------------------------- */
|
||||
function detectSource(): SalesLeadSource {
|
||||
if (typeof document === 'undefined') return 'landing_page'
|
||||
const ref = document.referrer || ''
|
||||
if (ref.includes('/pricing')) return 'pricing_page'
|
||||
return 'landing_page'
|
||||
}
|
||||
|
||||
const TEAM_SIZE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: '', label: 'Select team size' },
|
||||
{ value: '1-2', label: '1–2' },
|
||||
{ value: '3-5', label: '3–5' },
|
||||
{ value: '6-10', label: '6–10' },
|
||||
{ value: '11-25', label: '11–25' },
|
||||
{ value: '26+', label: 'More than 26' },
|
||||
]
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
email: string
|
||||
company: string
|
||||
team_size: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const INITIAL: FormState = {
|
||||
name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
team_size: '',
|
||||
message: '',
|
||||
}
|
||||
|
||||
function ContactSalesNotFound() {
|
||||
return (
|
||||
<div
|
||||
data-testid="contact-sales-not-found"
|
||||
style={{
|
||||
minHeight: '60vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
padding: '2rem',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>Page not found</h1>
|
||||
<p style={{ color: '#9198a8' }}>This page is not available.</p>
|
||||
<Link to="/login" style={{ color: '#60a5fa', marginTop: '1rem' }}>
|
||||
Go to login
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContactSalesPage() {
|
||||
const appConfig = useAppConfig()
|
||||
const [form, setForm] = useState<FormState>(INITIAL)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const calendlyUrl = useMemo(() => {
|
||||
const raw = import.meta.env.VITE_CALENDLY_URL
|
||||
return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : ''
|
||||
}, [])
|
||||
|
||||
// Self-serve disabled: 404. (Same pattern as PricingPage — done after hooks.)
|
||||
if (!appConfig.isLoading && !appConfig.self_serve_enabled) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Page not found" />
|
||||
<ContactSalesNotFound />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const handleChange =
|
||||
(field: keyof FormState) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
setForm((prev) => ({ ...prev, [field]: e.target.value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (submitting) return
|
||||
|
||||
const name = form.name.trim()
|
||||
const email = form.email.trim()
|
||||
const company = form.company.trim()
|
||||
|
||||
if (!name || !email || !company) {
|
||||
setError('Please fill in name, work email, and company.')
|
||||
return
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setError('Enter a valid work email address.')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await salesApi.createLead({
|
||||
name,
|
||||
email,
|
||||
company,
|
||||
team_size: form.team_size || undefined,
|
||||
message: form.message.trim() || undefined,
|
||||
source: detectSource(),
|
||||
})
|
||||
setSubmitted(true)
|
||||
} catch {
|
||||
// The backend may rate-limit (429) or reject for validation; surface a
|
||||
// generic message and allow retry. Don't leak internal errors.
|
||||
setError('Something went wrong. Please try again or email hello@resolutionflow.com.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
<PageMeta
|
||||
title="Talk to Sales"
|
||||
description="Get in touch with the ResolutionFlow team about Enterprise plans, custom seats, SSO, and onboarding for your MSP."
|
||||
/>
|
||||
|
||||
<main
|
||||
className="landing-main"
|
||||
style={{ paddingTop: '4rem', paddingBottom: '4rem' }}
|
||||
>
|
||||
<section
|
||||
style={{
|
||||
maxWidth: '640px',
|
||||
margin: '0 auto',
|
||||
padding: '3rem 1.5rem 1.5rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: 'clamp(1.75rem, 3.5vw, 2.5rem)',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.15,
|
||||
margin: '0 0 0.75rem',
|
||||
}}
|
||||
>
|
||||
Talk to Sales
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
color: 'var(--lp-text-body)',
|
||||
fontSize: '1.05rem',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Tell us about your MSP. We’ll reach out within 1 business day.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
style={{
|
||||
maxWidth: '560px',
|
||||
margin: '0 auto',
|
||||
padding: '1rem 1.5rem 3rem',
|
||||
}}
|
||||
>
|
||||
{submitted ? (
|
||||
<div
|
||||
data-testid="contact-sales-confirmation"
|
||||
style={{
|
||||
background: 'var(--lp-card)',
|
||||
border: '1px solid var(--lp-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '2rem 1.75rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
margin: '0 0 0.75rem',
|
||||
}}
|
||||
>
|
||||
Thanks — we’ll reach out within 1 business day.
|
||||
</h2>
|
||||
{calendlyUrl && (
|
||||
<div data-testid="calendly-block">
|
||||
<p style={{ color: 'var(--lp-text-body)', margin: '0 0 1rem' }}>
|
||||
Want to skip ahead?
|
||||
</p>
|
||||
<a
|
||||
data-testid="calendly-link"
|
||||
href={calendlyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.65rem 1.25rem',
|
||||
background: 'var(--lp-accent)',
|
||||
color: '#0d0f15',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Book a time
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
data-testid="contact-sales-form"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
background: 'var(--lp-card)',
|
||||
border: '1px solid var(--lp-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.75rem',
|
||||
}}
|
||||
>
|
||||
<Field label="Name" htmlFor="cs-name" required>
|
||||
<input
|
||||
id="cs-name"
|
||||
data-testid="cs-name"
|
||||
type="text"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={handleChange('name')}
|
||||
autoComplete="name"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Work email" htmlFor="cs-email" required>
|
||||
<input
|
||||
id="cs-email"
|
||||
data-testid="cs-email"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={handleChange('email')}
|
||||
autoComplete="email"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Company" htmlFor="cs-company" required>
|
||||
<input
|
||||
id="cs-company"
|
||||
data-testid="cs-company"
|
||||
type="text"
|
||||
required
|
||||
value={form.company}
|
||||
onChange={handleChange('company')}
|
||||
autoComplete="organization"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Team size" htmlFor="cs-team-size">
|
||||
<select
|
||||
id="cs-team-size"
|
||||
data-testid="cs-team-size"
|
||||
value={form.team_size}
|
||||
onChange={handleChange('team_size')}
|
||||
style={inputStyle}
|
||||
>
|
||||
{TEAM_SIZE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="What brought you here?" htmlFor="cs-message">
|
||||
<textarea
|
||||
id="cs-message"
|
||||
data-testid="cs-message"
|
||||
rows={4}
|
||||
value={form.message}
|
||||
onChange={handleChange('message')}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="cs-error"
|
||||
style={{ color: '#f87171', fontSize: '0.9rem' }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="cs-submit"
|
||||
disabled={submitting}
|
||||
style={{
|
||||
padding: '0.75rem 1.25rem',
|
||||
background: 'var(--lp-accent)',
|
||||
color: '#0d0f15',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
cursor: submitting ? 'not-allowed' : 'pointer',
|
||||
opacity: submitting ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Sending…' : 'Submit'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
htmlFor: string
|
||||
required?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{required && (
|
||||
<span aria-hidden="true" style={{ color: 'var(--lp-accent)', marginLeft: '0.25rem' }}>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '0.6rem 0.75rem',
|
||||
background: 'var(--lp-bg-alt, #1a1d26)',
|
||||
border: '1px solid var(--lp-border)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: '0.95rem',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
}
|
||||
|
||||
export default ContactSalesPage
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||
import '@/styles/landing.css'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{
|
||||
q: 'How is this different from just using ChatGPT?',
|
||||
@@ -29,11 +28,9 @@ const FAQ_ITEMS = [
|
||||
]
|
||||
|
||||
export default function LandingPage() {
|
||||
const appConfig = useAppConfig()
|
||||
const [navScrolled, setNavScrolled] = useState(false)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [betaEmail, setBetaEmail] = useState('')
|
||||
const [betaStatus, setBetaStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
|
||||
const [betaError, setBetaError] = useState('')
|
||||
const [openFaq, setOpenFaq] = useState<number | null>(null)
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -71,32 +68,6 @@ export default function LandingPage() {
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const handleBetaSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = betaEmail.trim()
|
||||
if (!trimmed || betaStatus === 'sending') return
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
|
||||
setBetaStatus('error')
|
||||
setBetaError('Enter a valid email address.')
|
||||
return
|
||||
}
|
||||
setBetaStatus('sending')
|
||||
setBetaError('')
|
||||
try {
|
||||
const resp = await fetch(`${API_URL}/api/v1/beta-signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: trimmed }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Signup failed')
|
||||
setBetaStatus('sent')
|
||||
setBetaEmail('')
|
||||
} catch {
|
||||
setBetaStatus('error')
|
||||
setBetaError('Could not complete signup. Please try again or email hello@resolutionflow.com.')
|
||||
}
|
||||
}, [betaEmail, betaStatus])
|
||||
|
||||
const toggleFaq = (index: number) => {
|
||||
setOpenFaq(prev => prev === index ? null : index)
|
||||
}
|
||||
@@ -174,6 +145,15 @@ export default function LandingPage() {
|
||||
</p>
|
||||
<div className="landing-hero-actions">
|
||||
<Link to="/register" className="landing-btn-hero-primary">Start Free</Link>
|
||||
{appConfig.self_serve_enabled && (
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="landing-btn-hero-secondary"
|
||||
data-testid="landing-see-pricing"
|
||||
>
|
||||
See pricing
|
||||
</Link>
|
||||
)}
|
||||
<a href="#how-it-works" className="landing-btn-hero-secondary">See How It Works</a>
|
||||
</div>
|
||||
<p className="landing-hero-credibility">
|
||||
@@ -422,34 +402,10 @@ export default function LandingPage() {
|
||||
<section className="landing-cta-section landing-reveal">
|
||||
<div className="landing-cta-inner">
|
||||
<h2>Ready to stop writing ticket notes?</h2>
|
||||
<p>Join the beta. Troubleshoot your next ticket with FlowPilot.</p>
|
||||
<form className="landing-cta-email-form" onSubmit={handleBetaSubmit} noValidate>
|
||||
<div className="landing-cta-input-wrap">
|
||||
<input
|
||||
type="email"
|
||||
className="landing-cta-email-input"
|
||||
placeholder="you@yourmsp.com"
|
||||
value={betaEmail}
|
||||
onChange={e => {
|
||||
setBetaEmail(e.target.value)
|
||||
if (betaStatus === 'error') { setBetaStatus('idle'); setBetaError('') }
|
||||
}}
|
||||
required
|
||||
aria-describedby="beta-status"
|
||||
/>
|
||||
<button type="submit" className="landing-btn-hero-primary" disabled={betaStatus === 'sending'}>
|
||||
{betaStatus === 'sending' ? 'Joining\u2026' : betaStatus === 'sent' ? 'Joined!' : 'Join Beta'}
|
||||
</button>
|
||||
</div>
|
||||
<div id="beta-status" aria-live="polite" className="landing-cta-status">
|
||||
{betaStatus === 'sent' && (
|
||||
<p className="landing-cta-success">You're in. We'll send beta access details soon.</p>
|
||||
)}
|
||||
{betaStatus === 'error' && betaError && (
|
||||
<p className="landing-cta-error">{betaError}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<p>Get early access. Troubleshoot your next ticket with FlowPilot.</p>
|
||||
<div className="landing-cta-actions">
|
||||
<Link to="/register?from=beta" className="landing-btn-hero-primary">Get started</Link>
|
||||
</div>
|
||||
<p className="landing-cta-fine-print">Free to start. No credit card required.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -58,6 +58,7 @@ export function OAuthCallbackPage() {
|
||||
}
|
||||
|
||||
if (oauthError) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- callback URL validation maps directly into local error state
|
||||
setError(`OAuth error: ${oauthError}`)
|
||||
return
|
||||
}
|
||||
|
||||
439
frontend/src/pages/PricingPage.tsx
Normal file
439
frontend/src/pages/PricingPage.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
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<PlanColumn, boolean>
|
||||
}> = [
|
||||
{ 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 (
|
||||
<div
|
||||
data-testid="pricing-not-found"
|
||||
style={{
|
||||
minHeight: '60vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
padding: '2rem',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>Page not found</h1>
|
||||
<p style={{ color: '#9198a8' }}>This page is not available.</p>
|
||||
<Link to="/login" style={{ color: '#60a5fa', marginTop: '1rem' }}>
|
||||
Go to login
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
data-testid={`plan-card-${fallback.plan}`}
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: 'var(--lp-card)',
|
||||
border: recommended
|
||||
? '2px solid var(--lp-accent)'
|
||||
: '1px solid var(--lp-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '2rem 1.75rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.25rem',
|
||||
}}
|
||||
>
|
||||
{recommended && (
|
||||
<span
|
||||
data-testid="recommended-badge"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: '4px 12px',
|
||||
background: 'var(--lp-accent)',
|
||||
color: '#0d0f15',
|
||||
borderRadius: '999px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</h3>
|
||||
<p style={{ margin: '0.5rem 0 0', color: 'var(--lp-text-secondary)', fontSize: '0.9rem' }}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ minHeight: '3rem' }}>
|
||||
{hidePrice ? (
|
||||
<div style={{ color: 'var(--lp-text-heading)', fontSize: '1.25rem', fontWeight: 600 }}>
|
||||
Custom pricing
|
||||
</div>
|
||||
) : monthlyCents != null ? (
|
||||
<div>
|
||||
<span style={{ color: 'var(--lp-text-heading)', fontSize: '2.25rem', fontWeight: 700 }}>
|
||||
{formatPrice(monthlyCents)}
|
||||
</span>
|
||||
<span style={{ color: 'var(--lp-text-secondary)', marginLeft: '0.35rem' }}>/ month</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--lp-text-secondary)' }}>Contact us</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={ctaHref}
|
||||
data-testid={ctaTestId}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
textAlign: 'center',
|
||||
padding: '0.75rem 1.25rem',
|
||||
background: recommended ? 'var(--lp-accent)' : 'transparent',
|
||||
color: recommended ? '#0d0f15' : 'var(--lp-text-heading)',
|
||||
border: recommended ? 'none' : '1px solid var(--lp-border-hover)',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{ctaLabel}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PricingPage() {
|
||||
const appConfig = useAppConfig()
|
||||
const [plans, setPlans] = useState<PublicPlanResponse[] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Fetch plans on mount when self-serve is enabled.
|
||||
useEffect(() => {
|
||||
if (appConfig.isLoading) return
|
||||
if (!appConfig.self_serve_enabled) return
|
||||
|
||||
let cancelled = false
|
||||
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 (
|
||||
<>
|
||||
<PageMeta title="Page not found" />
|
||||
<PricingNotFound />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const planByName = (name: string) =>
|
||||
plans?.find((p) => p.plan.toLowerCase() === name) ?? null
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
<PageMeta
|
||||
title="Pricing"
|
||||
description="ResolutionFlow plans for MSPs — Starter, Pro, and Enterprise. Try Pro free for 14 days, no credit card required."
|
||||
/>
|
||||
|
||||
<main className="landing-main" style={{ paddingTop: '4rem', paddingBottom: '4rem' }}>
|
||||
{/* ---- HERO ---- */}
|
||||
<section
|
||||
style={{
|
||||
maxWidth: '720px',
|
||||
margin: '0 auto',
|
||||
padding: '4rem 1.5rem 2rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: 'clamp(2rem, 4vw, 2.75rem)',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.15,
|
||||
margin: '0 0 1rem',
|
||||
}}
|
||||
>
|
||||
Simple pricing for MSPs of every size
|
||||
</h1>
|
||||
<p
|
||||
data-testid="hero-trial-line"
|
||||
style={{
|
||||
color: 'var(--lp-text-body)',
|
||||
fontSize: '1.125rem',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Try Pro free for 14 days. No credit card required.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ---- PLAN CARDS ---- */}
|
||||
<section
|
||||
aria-label="Plans"
|
||||
style={{
|
||||
maxWidth: '1100px',
|
||||
margin: '0 auto',
|
||||
padding: '2rem 1.5rem',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<PlanCard
|
||||
plan={planByName('starter')}
|
||||
fallback={{
|
||||
plan: 'starter',
|
||||
display_name: 'Starter',
|
||||
description: 'For solo techs getting structured.',
|
||||
}}
|
||||
ctaLabel="Start free trial"
|
||||
ctaHref="/register?plan=starter"
|
||||
ctaTestId="cta-starter"
|
||||
/>
|
||||
<PlanCard
|
||||
plan={planByName('pro')}
|
||||
recommended
|
||||
fallback={{
|
||||
plan: 'pro',
|
||||
display_name: 'Pro',
|
||||
description: 'For growing MSP teams. PSA integration + KB Accelerator.',
|
||||
}}
|
||||
ctaLabel="Start free trial"
|
||||
ctaHref="/register?plan=pro"
|
||||
ctaTestId="cta-pro"
|
||||
/>
|
||||
<PlanCard
|
||||
plan={planByName('enterprise')}
|
||||
hidePrice
|
||||
fallback={{
|
||||
plan: 'enterprise',
|
||||
display_name: 'Enterprise',
|
||||
description: 'Custom branding, custom seats, and a dedicated success contact.',
|
||||
}}
|
||||
ctaLabel="Talk to sales"
|
||||
ctaHref="/contact-sales"
|
||||
ctaTestId="cta-enterprise"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{loading && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
style={{ textAlign: 'center', color: 'var(--lp-text-secondary)' }}
|
||||
>
|
||||
Loading pricing…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
role="status"
|
||||
style={{ textAlign: 'center', color: 'var(--lp-text-secondary)', marginTop: '0.5rem' }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- COMPARISON TABLE ---- */}
|
||||
<section
|
||||
aria-label="Plan comparison"
|
||||
style={{
|
||||
maxWidth: '1000px',
|
||||
margin: '3rem auto 2rem',
|
||||
padding: '0 1.5rem',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
margin: '0 0 1rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Compare plans
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
overflowX: 'auto',
|
||||
border: '1px solid var(--lp-border)',
|
||||
borderRadius: '12px',
|
||||
}}
|
||||
>
|
||||
<table
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
color: 'var(--lp-text-body)',
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--lp-bg-alt)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem 1rem', fontWeight: 600 }}>
|
||||
Feature
|
||||
</th>
|
||||
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Starter</th>
|
||||
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Pro</th>
|
||||
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Enterprise</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMPARISON_ROWS.map((row) => (
|
||||
<tr key={row.feature} style={{ borderTop: '1px solid var(--lp-border)' }}>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>{row.feature}</td>
|
||||
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
|
||||
{row.values.starter ? '✓' : '—'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
|
||||
{row.values.pro ? '✓' : '—'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
|
||||
{row.values.enterprise ? '✓' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- TESTIMONIAL SLOT (placeholder) ---- */}
|
||||
<section
|
||||
aria-label="Customer testimonial"
|
||||
style={{
|
||||
maxWidth: '720px',
|
||||
margin: '3rem auto 2rem',
|
||||
padding: '2rem 1.5rem',
|
||||
background: 'var(--lp-card)',
|
||||
border: '1px solid var(--lp-border)',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
data-testid="testimonial-slot"
|
||||
>
|
||||
<blockquote
|
||||
style={{
|
||||
fontStyle: 'italic',
|
||||
color: 'var(--lp-text-body)',
|
||||
fontSize: '1.05rem',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
"Pilot testimonials coming soon."
|
||||
</blockquote>
|
||||
<div style={{ marginTop: '0.75rem', color: 'var(--lp-text-secondary)', fontSize: '0.9rem' }}>
|
||||
ResolutionFlow pilot, 2026
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- TRUST STRIP ---- */}
|
||||
<section
|
||||
aria-label="Trust"
|
||||
data-testid="trust-strip"
|
||||
style={{
|
||||
maxWidth: '900px',
|
||||
margin: '2rem auto 0',
|
||||
padding: '1rem 1.5rem',
|
||||
color: 'var(--lp-text-secondary)',
|
||||
fontSize: '0.9rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Built on Stripe + AWS · Encrypted in transit and at rest
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PricingPage
|
||||
@@ -33,7 +33,8 @@ function randomState(): string {
|
||||
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/** Build provider authorize URL. Exported for tests. */
|
||||
/** Build provider authorize URL. Exported for tests and invite OAuth handoff. */
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- pure helper shared with AcceptInvitePage and unit tests
|
||||
export function buildOAuthAuthorizeUrl(
|
||||
provider: 'google' | 'microsoft',
|
||||
state: string,
|
||||
|
||||
146
frontend/src/pages/__tests__/ContactSalesPage.test.tsx
Normal file
146
frontend/src/pages/__tests__/ContactSalesPage.test.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
|
||||
import { ContactSalesPage } from '../ContactSalesPage'
|
||||
import { salesApi } from '@/api/sales'
|
||||
import {
|
||||
__resetAppConfigCache,
|
||||
__setAppConfigCache,
|
||||
} from '@/hooks/useAppConfig'
|
||||
|
||||
vi.mock('@/api/sales', () => ({
|
||||
salesApi: {
|
||||
createLead: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/contact-sales']}>
|
||||
<ContactSalesPage />
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function fillRequiredFields() {
|
||||
fireEvent.change(screen.getByTestId('cs-name'), { target: { value: 'Jane Doe' } })
|
||||
fireEvent.change(screen.getByTestId('cs-email'), { target: { value: 'jane@acme.com' } })
|
||||
fireEvent.change(screen.getByTestId('cs-company'), { target: { value: 'Acme MSP' } })
|
||||
}
|
||||
|
||||
describe('ContactSalesPage', () => {
|
||||
beforeEach(() => {
|
||||
__resetAppConfigCache()
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('submits form and shows confirmation', async () => {
|
||||
vi.stubEnv('VITE_CALENDLY_URL', 'https://calendly.com/resolutionflow/sales')
|
||||
vi.mocked(salesApi.createLead).mockResolvedValue({
|
||||
id: 'fake-uuid',
|
||||
status: 'received',
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
fillRequiredFields()
|
||||
fireEvent.change(screen.getByTestId('cs-team-size'), { target: { value: '11-25' } })
|
||||
fireEvent.change(screen.getByTestId('cs-message'), {
|
||||
target: { value: 'Looking at Enterprise pricing.' },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('cs-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(salesApi.createLead).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const payload = vi.mocked(salesApi.createLead).mock.calls[0][0]
|
||||
expect(payload).toMatchObject({
|
||||
name: 'Jane Doe',
|
||||
email: 'jane@acme.com',
|
||||
company: 'Acme MSP',
|
||||
team_size: '11-25',
|
||||
message: 'Looking at Enterprise pricing.',
|
||||
})
|
||||
// Default source is landing_page (no /pricing in referrer in jsdom).
|
||||
expect(payload.source).toBe('landing_page')
|
||||
|
||||
// Confirmation surface replaces the form.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByTestId('contact-sales-form')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Thanks/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Calendly section when VITE_CALENDLY_URL unset', async () => {
|
||||
vi.stubEnv('VITE_CALENDLY_URL', '')
|
||||
vi.mocked(salesApi.createLead).mockResolvedValue({
|
||||
id: 'fake-uuid',
|
||||
status: 'received',
|
||||
})
|
||||
|
||||
renderPage()
|
||||
fillRequiredFields()
|
||||
fireEvent.click(screen.getByTestId('cs-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('calendly-block')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('calendly-link')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables submit button while in flight to prevent duplicate submissions', async () => {
|
||||
let resolveSubmit: (() => void) | null = null
|
||||
vi.mocked(salesApi.createLead).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSubmit = () => resolve({ id: 'fake-uuid', status: 'received' })
|
||||
}),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
fillRequiredFields()
|
||||
|
||||
const submit = screen.getByTestId('cs-submit') as HTMLButtonElement
|
||||
fireEvent.click(submit)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submit.disabled).toBe(true)
|
||||
})
|
||||
|
||||
// A second click while in flight should be a no-op.
|
||||
fireEvent.click(submit)
|
||||
expect(salesApi.createLead).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveSubmit?.()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 404 when self_serve_enabled is false', () => {
|
||||
__resetAppConfigCache()
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: false,
|
||||
oauth_providers: [],
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByTestId('contact-sales-not-found')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('contact-sales-form')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
69
frontend/src/pages/__tests__/LandingPage.test.tsx
Normal file
69
frontend/src/pages/__tests__/LandingPage.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
|
||||
import LandingPage from '../LandingPage'
|
||||
import {
|
||||
__resetAppConfigCache,
|
||||
__setAppConfigCache,
|
||||
} from '@/hooks/useAppConfig'
|
||||
|
||||
// jsdom does not provide IntersectionObserver. LandingPage uses it for
|
||||
// scroll-reveal animations; stub a no-op so the page can mount.
|
||||
beforeAll(() => {
|
||||
// @ts-expect-error — test-only stub
|
||||
globalThis.IntersectionObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LandingPage />
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('LandingPage', () => {
|
||||
beforeEach(() => {
|
||||
__resetAppConfigCache()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows See pricing CTA when self_serve_enabled is true', async () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('landing-see-pricing')).toBeInTheDocument()
|
||||
})
|
||||
const cta = screen.getByTestId('landing-see-pricing')
|
||||
expect(cta).toHaveAttribute('href', '/pricing')
|
||||
expect(cta).toHaveTextContent(/See pricing/i)
|
||||
})
|
||||
|
||||
it('hides See pricing CTA when self_serve_enabled is false', async () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: false,
|
||||
oauth_providers: [],
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
// Hero "Start Free" still renders, but the gated /pricing CTA does not.
|
||||
expect(screen.queryByTestId('landing-see-pricing')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -13,10 +13,13 @@ vi.mock('@/api/auth', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const mockSetTokens = vi.fn()
|
||||
const mockFetchUser = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('@/store/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
setTokens: vi.fn(),
|
||||
fetchUser: vi.fn().mockResolvedValue(undefined),
|
||||
setTokens: mockSetTokens,
|
||||
fetchUser: mockFetchUser,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -79,3 +82,40 @@ describe('OAuthCallbackPage CSRF state validation', () => {
|
||||
expect(authApi.googleCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuthCallbackPage successful callback', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('persists tokens via setTokens (which marks the store authenticated) and fetches the user', async () => {
|
||||
sessionStorage.setItem('rf-oauth-state', 'csrf-value')
|
||||
;(authApi.googleCallback as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
access_token: 'access-123',
|
||||
refresh_token: 'refresh-456',
|
||||
token_type: 'bearer',
|
||||
is_new_user: false,
|
||||
})
|
||||
|
||||
renderAt('/auth/google/callback?code=auth-code-123&state=csrf-value')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetTokens).toHaveBeenCalledWith({
|
||||
access_token: 'access-123',
|
||||
refresh_token: 'refresh-456',
|
||||
token_type: 'bearer',
|
||||
})
|
||||
})
|
||||
expect(mockFetchUser).toHaveBeenCalled()
|
||||
// Tokens are also persisted for the apiClient interceptor.
|
||||
expect(localStorage.getItem('access_token')).toBe('access-123')
|
||||
expect(localStorage.getItem('refresh_token')).toBe('refresh-456')
|
||||
})
|
||||
})
|
||||
|
||||
162
frontend/src/pages/__tests__/PricingPage.test.tsx
Normal file
162
frontend/src/pages/__tests__/PricingPage.test.tsx
Normal file
@@ -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(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/pricing']}>
|
||||
<PricingPage />
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
267
frontend/src/pages/account/BillingPage.tsx
Normal file
267
frontend/src/pages/account/BillingPage.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { CreditCard, AlertCircle, Loader2, ExternalLink, Crown } from 'lucide-react'
|
||||
|
||||
import { billingApi } from '@/api/billing'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
import { BillingPortalError } from '@/types/billing'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '—'
|
||||
return new Date(value).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'trialing':
|
||||
return 'Trialing'
|
||||
case 'active':
|
||||
return 'Active'
|
||||
case 'past_due':
|
||||
return 'Past due'
|
||||
case 'canceled':
|
||||
return 'Canceled'
|
||||
case 'incomplete':
|
||||
return 'Incomplete'
|
||||
case 'complimentary':
|
||||
return 'Complimentary'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
function statusToneClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
case 'complimentary':
|
||||
return 'text-success'
|
||||
case 'trialing':
|
||||
return 'text-info'
|
||||
case 'past_due':
|
||||
case 'incomplete':
|
||||
return 'text-warning'
|
||||
case 'canceled':
|
||||
return 'text-danger'
|
||||
default:
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
}
|
||||
|
||||
export function BillingPage() {
|
||||
const subscription = useBillingStore((s) => s.subscription)
|
||||
const planBilling = useBillingStore((s) => s.planBilling)
|
||||
const isLoading = useBillingStore((s) => s.isLoading)
|
||||
|
||||
const [openingPortal, setOpeningPortal] = useState(false)
|
||||
|
||||
const status = subscription?.status ?? null
|
||||
const isComplimentary = status === 'complimentary'
|
||||
const isTrialing = status === 'trialing'
|
||||
const isPastDue = status === 'past_due'
|
||||
const isCanceled = status === 'canceled'
|
||||
|
||||
const handleOpenPortal = async () => {
|
||||
setOpeningPortal(true)
|
||||
try {
|
||||
const { url } = await billingApi.getPortalSession()
|
||||
window.location.href = url
|
||||
} catch (err) {
|
||||
if (err instanceof BillingPortalError) {
|
||||
if (err.code === 'no_stripe_customer') {
|
||||
toast.error('Complete checkout first to access billing portal.')
|
||||
} else {
|
||||
toast.error('Billing portal is not available right now.')
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to open billing portal.')
|
||||
}
|
||||
setOpeningPortal(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading && !subscription) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Billing" />
|
||||
<div>
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-8 w-8 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
|
||||
Billing
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Manage your subscription, payment method, and billing history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Past-due banner ────────────────────────────────────────────── */}
|
||||
{isPastDue && (
|
||||
<div
|
||||
data-testid="past-due-banner"
|
||||
className={cn(
|
||||
'mb-6 flex flex-wrap items-start gap-3 rounded-lg border border-warning/30',
|
||||
'bg-warning-dim p-4 text-foreground',
|
||||
)}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Your last payment failed.</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Update your payment method to keep access to ResolutionFlow.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={openingPortal}
|
||||
onClick={handleOpenPortal}
|
||||
data-testid="past-due-update-payment"
|
||||
>
|
||||
Update payment method
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Subscription summary card ──────────────────────────────────── */}
|
||||
<div className="card-flat max-w-xl space-y-5 p-6">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Crown className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{planBilling?.display_name ?? 'No active plan'}
|
||||
</span>
|
||||
</div>
|
||||
{subscription && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 text-xs',
|
||||
statusToneClass(subscription.status),
|
||||
)}
|
||||
>
|
||||
{statusLabel(subscription.status)}
|
||||
{subscription.cancel_at_period_end && ' · cancels at period end'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{subscription?.seat_limit != null && (
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-muted-foreground">Seats</div>
|
||||
<div className="text-sm tabular-nums text-foreground">
|
||||
{subscription.seat_limit}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 border-t border-border pt-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isCanceled ? 'Ends' : isTrialing ? 'Trial ends' : 'Next renewal'}
|
||||
</div>
|
||||
<div className="text-sm tabular-nums text-foreground">
|
||||
{isComplimentary ? '—' : formatDate(subscription?.current_period_end)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Plan started</div>
|
||||
<div className="text-sm tabular-nums text-foreground">
|
||||
{formatDate(subscription?.current_period_start)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* State-specific messaging ------------------------------------ */}
|
||||
{isComplimentary && (
|
||||
<div
|
||||
data-testid="complimentary-message"
|
||||
className="rounded-md bg-success-dim p-3 text-xs text-success"
|
||||
>
|
||||
Complimentary Pro — no billing required.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTrialing && (
|
||||
<div
|
||||
data-testid="trial-message"
|
||||
className="rounded-md bg-info-dim p-3 text-xs text-info"
|
||||
>
|
||||
Trial ends {formatDate(subscription?.current_period_end)} — pick a plan
|
||||
to continue.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCanceled && (
|
||||
<div
|
||||
data-testid="canceled-message"
|
||||
className="rounded-md bg-muted p-3 text-xs text-muted-foreground"
|
||||
>
|
||||
Subscription canceled. Reactivate by picking a plan.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Actions ────────────────────────────────────────────────────── */}
|
||||
{!isComplimentary && (
|
||||
<div className="mt-6 flex max-w-xl flex-wrap gap-3">
|
||||
{(isTrialing || isCanceled) && (
|
||||
<Link
|
||||
to="/account/billing/select-plan"
|
||||
data-testid="select-plan-link"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2',
|
||||
'text-sm font-semibold text-white hover:brightness-110',
|
||||
)}
|
||||
>
|
||||
Pick a plan
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!isTrialing && !isCanceled && (
|
||||
<Link
|
||||
to="/account/billing/select-plan"
|
||||
data-testid="change-plan-link"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-lg border border-border',
|
||||
'bg-input px-4 py-2 text-sm font-medium text-foreground',
|
||||
'hover:border-border-hover',
|
||||
)}
|
||||
>
|
||||
Change plan
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={openingPortal}
|
||||
onClick={handleOpenPortal}
|
||||
data-testid="manage-billing-button"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Manage billing
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BillingPage
|
||||
354
frontend/src/pages/account/SelectPlanPage.tsx
Normal file
354
frontend/src/pages/account/SelectPlanPage.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Check, CreditCard, Loader2 } from 'lucide-react'
|
||||
|
||||
import { billingApi } from '@/api/billing'
|
||||
import { plansApi, type PublicPlanResponse } from '@/api/plans'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BillingInterval, CheckoutPlan } from '@/types/billing'
|
||||
|
||||
function formatPrice(cents: number | null | undefined): string {
|
||||
if (cents == null) return ''
|
||||
const dollars = cents / 100
|
||||
return `$${Math.round(dollars).toLocaleString()}`
|
||||
}
|
||||
|
||||
const PLAN_FALLBACK_FEATURES: Record<string, string[]> = {
|
||||
starter: ['AI Builder', 'Up to 1 seat', 'Email support'],
|
||||
pro: [
|
||||
'PSA Integration',
|
||||
'KB Accelerator',
|
||||
'AI Builder',
|
||||
'Priority support',
|
||||
],
|
||||
team: [
|
||||
'Everything in Pro',
|
||||
'Multi-seat collaboration',
|
||||
'Shared categories',
|
||||
],
|
||||
enterprise: [
|
||||
'Custom seats and SSO',
|
||||
'Custom branding',
|
||||
'Dedicated success contact',
|
||||
],
|
||||
}
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: PublicPlanResponse
|
||||
interval: BillingInterval
|
||||
isCurrent: boolean
|
||||
isEnterprise: boolean
|
||||
onSelect: (planKey: CheckoutPlan) => void
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
plan,
|
||||
interval,
|
||||
isCurrent,
|
||||
isEnterprise,
|
||||
onSelect,
|
||||
isSubmitting,
|
||||
}: PlanCardProps) {
|
||||
const planKey = plan.plan.toLowerCase() as CheckoutPlan
|
||||
const cents =
|
||||
interval === 'annual' ? plan.annual_price_cents : plan.monthly_price_cents
|
||||
const features = PLAN_FALLBACK_FEATURES[planKey] ?? []
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`plan-card-${planKey}`}
|
||||
className={cn(
|
||||
'flex flex-col gap-4 rounded-xl border p-6',
|
||||
isCurrent
|
||||
? 'border-primary/40 bg-primary/5'
|
||||
: 'border-border bg-card hover:border-border-hover',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{plan.display_name}
|
||||
</h3>
|
||||
{isCurrent && (
|
||||
<span
|
||||
data-testid={`plan-current-${planKey}`}
|
||||
className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
|
||||
>
|
||||
Current plan
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plan.description && (
|
||||
<p className="text-sm text-muted-foreground">{plan.description}</p>
|
||||
)}
|
||||
|
||||
<div className="min-h-[3rem]">
|
||||
{isEnterprise ? (
|
||||
<div className="text-base font-medium text-foreground">
|
||||
Custom pricing
|
||||
</div>
|
||||
) : cents != null ? (
|
||||
<div>
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
{formatPrice(cents)}
|
||||
</span>
|
||||
<span className="ml-1 text-sm text-muted-foreground">
|
||||
/ {interval === 'annual' ? 'year' : 'month'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">Contact us</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5 text-sm text-muted-foreground">
|
||||
{features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-success" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto pt-2">
|
||||
{isEnterprise ? (
|
||||
<Link
|
||||
to="/contact-sales"
|
||||
data-testid={`plan-cta-${planKey}`}
|
||||
className={cn(
|
||||
'inline-flex w-full items-center justify-center rounded-lg border border-border',
|
||||
'bg-input px-4 py-2 text-sm font-medium text-foreground',
|
||||
'hover:border-border-hover',
|
||||
)}
|
||||
>
|
||||
Talk to sales
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
data-testid={`plan-cta-${planKey}`}
|
||||
disabled={isCurrent || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={() => onSelect(planKey)}
|
||||
className="w-full"
|
||||
>
|
||||
{isCurrent ? 'Current plan' : 'Continue to checkout'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SelectPlanPage() {
|
||||
const subscription = useBillingStore((s) => s.subscription)
|
||||
const currentPlan = subscription?.plan ?? null
|
||||
const isCurrentActive =
|
||||
subscription?.status === 'active' || subscription?.status === 'trialing'
|
||||
|
||||
const [plans, setPlans] = useState<PublicPlanResponse[] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [interval, setInterval] = useState<BillingInterval>('monthly')
|
||||
const [seats, setSeats] = useState<number>(1)
|
||||
const [submittingPlan, setSubmittingPlan] = useState<CheckoutPlan | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
plansApi
|
||||
.getPublic()
|
||||
.then((data) => {
|
||||
if (cancelled) return
|
||||
// Sort by sort_order so the layout is stable.
|
||||
const sorted = [...data].sort((a, b) => a.sort_order - b.sort_order)
|
||||
setPlans(sorted)
|
||||
setLoadError(null)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setLoadError('Unable to load plans. Please try again.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const seedSeats = useMemo(() => {
|
||||
return subscription?.seat_limit && subscription.seat_limit > 0
|
||||
? subscription.seat_limit
|
||||
: 1
|
||||
}, [subscription?.seat_limit])
|
||||
|
||||
useEffect(() => {
|
||||
setSeats(seedSeats)
|
||||
}, [seedSeats])
|
||||
|
||||
const handleSelectPlan = async (planKey: CheckoutPlan) => {
|
||||
if (planKey === 'enterprise') return
|
||||
setSubmittingPlan(planKey)
|
||||
try {
|
||||
const { url } = await billingApi.createCheckoutSession({
|
||||
plan: planKey,
|
||||
seats: Math.max(1, Math.floor(seats)),
|
||||
billing_interval: interval,
|
||||
})
|
||||
window.location.href = url
|
||||
} catch {
|
||||
toast.error('Could not start checkout. Please try again.')
|
||||
setSubmittingPlan(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Pick a plan" />
|
||||
<div>
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-8 w-8 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
|
||||
Pick a plan
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Choose the plan that fits your team. You can change or cancel any
|
||||
time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Controls ───────────────────────────────────────────────────── */}
|
||||
<div className="mb-6 flex flex-wrap items-end gap-6">
|
||||
<div>
|
||||
<span className="block text-xs font-medium text-muted-foreground">
|
||||
Billing interval
|
||||
</span>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Billing interval"
|
||||
className="mt-2 inline-flex rounded-lg border border-border bg-card p-1 text-sm"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={interval === 'monthly'}
|
||||
data-testid="interval-monthly"
|
||||
onClick={() => setInterval('monthly')}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 font-medium',
|
||||
interval === 'monthly'
|
||||
? 'bg-primary text-white'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={interval === 'annual'}
|
||||
data-testid="interval-annual"
|
||||
onClick={() => setInterval('annual')}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 font-medium',
|
||||
interval === 'annual'
|
||||
? 'bg-primary text-white'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Annual
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="seats-input"
|
||||
className="block text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Seats
|
||||
</label>
|
||||
<input
|
||||
id="seats-input"
|
||||
data-testid="seats-input"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={seats}
|
||||
onChange={(e) => {
|
||||
const next = Number.parseInt(e.target.value, 10)
|
||||
if (Number.isFinite(next) && next >= 1) {
|
||||
setSeats(next)
|
||||
} else if (e.target.value === '') {
|
||||
setSeats(1)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'mt-2 w-24 rounded-lg border border-border bg-card px-3 py-1.5',
|
||||
'text-sm text-foreground',
|
||||
'focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Plan cards ─────────────────────────────────────────────────── */}
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadError && !loading && (
|
||||
<div className="rounded-md border border-danger/20 bg-danger-dim p-4 text-sm text-danger">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && plans && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{plans.map((plan) => {
|
||||
const planKey = plan.plan.toLowerCase()
|
||||
const isEnterprise = planKey === 'enterprise'
|
||||
const isCurrent = !!(
|
||||
isCurrentActive &&
|
||||
currentPlan &&
|
||||
currentPlan.toLowerCase() === planKey
|
||||
)
|
||||
return (
|
||||
<PlanCard
|
||||
key={plan.plan}
|
||||
plan={plan}
|
||||
interval={interval}
|
||||
isCurrent={isCurrent}
|
||||
isEnterprise={isEnterprise}
|
||||
onSelect={handleSelectPlan}
|
||||
isSubmitting={submittingPlan === planKey}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/account/billing"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back to billing
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectPlanPage
|
||||
206
frontend/src/pages/account/__tests__/BillingPage.test.tsx
Normal file
206
frontend/src/pages/account/__tests__/BillingPage.test.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
import { BillingPage } from '../BillingPage'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
import { BillingPortalError } from '@/types/billing'
|
||||
import type { SubscriptionState, PlanBillingState } from '@/types/billing'
|
||||
|
||||
vi.mock('@/api/billing', () => ({
|
||||
billingApi: {
|
||||
getPortalSession: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { billingApi } from '@/api/billing'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
const getPortalSession = billingApi.getPortalSession as unknown as ReturnType<typeof vi.fn>
|
||||
const toastError = toast.error as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
function setBilling(opts: {
|
||||
subscription: SubscriptionState | null
|
||||
planBilling?: PlanBillingState | null
|
||||
}) {
|
||||
useBillingStore.setState({
|
||||
subscription: opts.subscription,
|
||||
planBilling:
|
||||
opts.planBilling ??
|
||||
({
|
||||
display_name: 'Pro',
|
||||
description: 'Pro plan',
|
||||
monthly_price_cents: 4900,
|
||||
annual_price_cents: 49000,
|
||||
} as PlanBillingState),
|
||||
planLimits: {},
|
||||
enabledFeatures: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<BillingPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('BillingPage', () => {
|
||||
beforeEach(() => {
|
||||
getPortalSession.mockReset()
|
||||
toastError.mockReset()
|
||||
useBillingStore.setState({
|
||||
subscription: null,
|
||||
planBilling: null,
|
||||
planLimits: {},
|
||||
enabledFeatures: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders subscription summary from useBillingStore', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'active',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-01T00:00:00Z',
|
||||
current_period_end: '2026-05-01T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: true,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Billing' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument()
|
||||
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||
// Seats shown
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows trial-ends message + Pick a plan CTA when trialing', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'trialing',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-22T00:00:00Z',
|
||||
current_period_end: '2026-05-06T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: false,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByTestId('trial-message').textContent).toMatch(/Trial ends/)
|
||||
const pickPlan = screen.getByTestId('select-plan-link')
|
||||
expect(pickPlan.getAttribute('href')).toBe('/account/billing/select-plan')
|
||||
})
|
||||
|
||||
it('shows past-due banner with update payment CTA when status=past_due', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'past_due',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-01T00:00:00Z',
|
||||
current_period_end: '2026-05-01T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: false,
|
||||
is_paid: true,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByTestId('past-due-banner')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('past-due-update-payment')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders complimentary message and hides CTAs when complimentary', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'complimentary',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-01T00:00:00Z',
|
||||
current_period_end: null,
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: null,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: true,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByTestId('complimentary-message')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('manage-billing-button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('select-plan-link')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('change-plan-link')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders canceled message + Pick a plan CTA when canceled', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'canceled',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-03-01T00:00:00Z',
|
||||
current_period_end: '2026-04-01T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: false,
|
||||
is_paid: false,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByTestId('canceled-message')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('select-plan-link')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows toast when portal session fails with no_stripe_customer', async () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'active',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-01T00:00:00Z',
|
||||
current_period_end: '2026-05-01T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: true,
|
||||
},
|
||||
})
|
||||
getPortalSession.mockRejectedValueOnce(
|
||||
new BillingPortalError('no_stripe_customer'),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
fireEvent.click(screen.getByTestId('manage-billing-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastError).toHaveBeenCalledWith(
|
||||
'Complete checkout first to access billing portal.',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
178
frontend/src/pages/account/__tests__/SelectPlanPage.test.tsx
Normal file
178
frontend/src/pages/account/__tests__/SelectPlanPage.test.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
import { SelectPlanPage } from '../SelectPlanPage'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
|
||||
vi.mock('@/api/billing', () => ({
|
||||
billingApi: {
|
||||
createCheckoutSession: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('@/api/plans', () => ({
|
||||
plansApi: {
|
||||
getPublic: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { billingApi } from '@/api/billing'
|
||||
import { plansApi } from '@/api/plans'
|
||||
|
||||
const createCheckoutSession = billingApi.createCheckoutSession as unknown as ReturnType<typeof vi.fn>
|
||||
const getPublic = plansApi.getPublic as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
const PLAN_FIXTURE = [
|
||||
{
|
||||
plan: 'starter',
|
||||
display_name: 'Starter',
|
||||
description: 'For solo techs.',
|
||||
monthly_price_cents: 1900,
|
||||
annual_price_cents: 19000,
|
||||
max_seats: 1,
|
||||
sort_order: 1,
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
plan: 'pro',
|
||||
display_name: 'Pro',
|
||||
description: 'For growing teams.',
|
||||
monthly_price_cents: 4900,
|
||||
annual_price_cents: 49000,
|
||||
max_seats: 5,
|
||||
sort_order: 2,
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
plan: 'enterprise',
|
||||
display_name: 'Enterprise',
|
||||
description: 'Custom.',
|
||||
monthly_price_cents: null,
|
||||
annual_price_cents: null,
|
||||
max_seats: null,
|
||||
sort_order: 3,
|
||||
is_public: true,
|
||||
},
|
||||
]
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<SelectPlanPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SelectPlanPage', () => {
|
||||
// Stub window.location.href setter so we can assert without a real navigation.
|
||||
let assignedHref: string | null = null
|
||||
const originalLocation = window.location
|
||||
|
||||
beforeEach(() => {
|
||||
getPublic.mockReset()
|
||||
createCheckoutSession.mockReset()
|
||||
assignedHref = null
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
...originalLocation,
|
||||
get href() {
|
||||
return assignedHref ?? originalLocation.href
|
||||
},
|
||||
set href(v: string) {
|
||||
assignedHref = v
|
||||
},
|
||||
},
|
||||
})
|
||||
useBillingStore.setState({
|
||||
subscription: null,
|
||||
planBilling: null,
|
||||
planLimits: {},
|
||||
enabledFeatures: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders plan cards from plansApi', async () => {
|
||||
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('plan-card-starter')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plan-card-enterprise')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Continue to checkout calls createCheckoutSession and redirects', async () => {
|
||||
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
||||
createCheckoutSession.mockResolvedValueOnce({ url: 'https://checkout.stripe.com/abc' })
|
||||
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Bump seats and switch to annual.
|
||||
fireEvent.change(screen.getByTestId('seats-input'), { target: { value: '3' } })
|
||||
fireEvent.click(screen.getByTestId('interval-annual'))
|
||||
|
||||
fireEvent.click(screen.getByTestId('plan-cta-pro'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createCheckoutSession).toHaveBeenCalledWith({
|
||||
plan: 'pro',
|
||||
seats: 3,
|
||||
billing_interval: 'annual',
|
||||
})
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(assignedHref).toBe('https://checkout.stripe.com/abc')
|
||||
})
|
||||
})
|
||||
|
||||
it('Talk to sales links to /contact-sales for enterprise', async () => {
|
||||
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('plan-cta-enterprise')).toBeInTheDocument()
|
||||
})
|
||||
const cta = screen.getByTestId('plan-cta-enterprise') as HTMLAnchorElement
|
||||
expect(cta.getAttribute('href')).toBe('/contact-sales')
|
||||
})
|
||||
|
||||
it('marks the active current plan as Current plan and disables its CTA', async () => {
|
||||
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
||||
useBillingStore.setState({
|
||||
subscription: {
|
||||
status: 'active',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-01T00:00:00Z',
|
||||
current_period_end: '2026-05-01T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: true,
|
||||
},
|
||||
planBilling: null,
|
||||
planLimits: {},
|
||||
enabledFeatures: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('plan-current-pro')).toBeInTheDocument()
|
||||
})
|
||||
const cta = screen.getByTestId('plan-cta-pro') as HTMLButtonElement
|
||||
expect(cta).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,8 @@ 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'))
|
||||
const ContactSalesPage = lazyWithRetry(() => import('@/pages/ContactSalesPage'))
|
||||
|
||||
// Standalone auth pages
|
||||
const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage'))
|
||||
@@ -98,6 +100,8 @@ const TargetListsPage = lazyWithRetry(() => import('@/pages/account/TargetListsP
|
||||
const ChatRetentionSettingsPage = lazyWithRetry(() => import('@/pages/account/ChatRetentionSettingsPage'))
|
||||
const IntegrationsPage = lazyWithRetry(() => import('@/pages/account/IntegrationsPage'))
|
||||
const BrandingSettingsPage = lazyWithRetry(() => import('@/pages/account/BrandingSettingsPage'))
|
||||
const BillingPage = lazyWithRetry(() => import('@/pages/account/BillingPage'))
|
||||
const SelectPlanPage = lazyWithRetry(() => import('@/pages/account/SelectPlanPage'))
|
||||
|
||||
/** Wraps a lazy-loaded page with Suspense + ErrorBoundary */
|
||||
function page(Component: React.LazyExoticComponent<React.ComponentType>) {
|
||||
@@ -131,6 +135,16 @@ export const router = sentryCreateBrowserRouter([
|
||||
element: page(TermsPage),
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/pricing',
|
||||
element: page(PricingPage),
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/contact-sales',
|
||||
element: page(ContactSalesPage),
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage />,
|
||||
@@ -326,6 +340,8 @@ export const router = sentryCreateBrowserRouter([
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
{ path: 'billing', element: page(BillingPage) },
|
||||
{ path: 'billing/select-plan', element: page(SelectPlanPage) },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
68
frontend/src/store/authStore.test.ts
Normal file
68
frontend/src/store/authStore.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { useAuthStore } from './authStore'
|
||||
import type { Token } from '@/types'
|
||||
|
||||
// Avoid pulling in real analytics / Sentry side effects during tests.
|
||||
vi.mock('@/lib/analytics', () => ({
|
||||
identifyUser: vi.fn(),
|
||||
resetAnalytics: vi.fn(),
|
||||
analytics: {
|
||||
loginSuccess: vi.fn(),
|
||||
accountCreated: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sentry/react', () => ({
|
||||
setUser: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('authStore.setTokens', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store to initial state between tests.
|
||||
useAuthStore.setState({
|
||||
user: null,
|
||||
token: null,
|
||||
account: null,
|
||||
subscription: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('marks the store as authenticated and persists the token', () => {
|
||||
const fakeToken: Token = {
|
||||
access_token: 'access-abc',
|
||||
refresh_token: 'refresh-xyz',
|
||||
token_type: 'bearer',
|
||||
}
|
||||
|
||||
useAuthStore.getState().setTokens(fakeToken)
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.token).toEqual(fakeToken)
|
||||
expect(state.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps isAuthenticated true when called again (refresh-token path)', () => {
|
||||
// Simulate an already-authenticated session (refresh interceptor case).
|
||||
useAuthStore.setState({
|
||||
token: {
|
||||
access_token: 'old',
|
||||
refresh_token: 'old-r',
|
||||
token_type: 'bearer',
|
||||
},
|
||||
isAuthenticated: true,
|
||||
})
|
||||
|
||||
useAuthStore.getState().setTokens({
|
||||
access_token: 'new',
|
||||
refresh_token: 'new-r',
|
||||
token_type: 'bearer',
|
||||
})
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.token?.access_token).toBe('new')
|
||||
expect(state.isAuthenticated).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -131,7 +131,13 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
},
|
||||
|
||||
setTokens: (token: Token) => set({ token }),
|
||||
// Storing tokens implies an active session — mark the store as
|
||||
// authenticated so <ProtectedRoute> doesn't bounce the user back to
|
||||
// /landing while fetchUser() is still inflight (e.g. immediately after
|
||||
// the OAuth callback exchange). The refresh interceptor in api/client.ts
|
||||
// also calls this; that path is already authenticated, so flipping the
|
||||
// flag has no effect there.
|
||||
setTokens: (token: Token) => set({ token, isAuthenticated: true }),
|
||||
clearError: () => set({ error: null }),
|
||||
setLoading: (loading: boolean) => set({ isLoading: loading }),
|
||||
}),
|
||||
|
||||
@@ -49,3 +49,45 @@ export interface BillingStateApiResponse {
|
||||
plan_limits: Record<string, unknown>
|
||||
enabled_features: Record<string, boolean>
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Checkout / Customer-Portal session types
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export type CheckoutPlan = 'starter' | 'pro' | 'team' | 'enterprise'
|
||||
export type BillingInterval = 'monthly' | 'annual'
|
||||
|
||||
export interface CheckoutSessionRequest {
|
||||
plan: CheckoutPlan
|
||||
seats: number
|
||||
billing_interval: BillingInterval
|
||||
}
|
||||
|
||||
export interface CheckoutSessionResponse {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface BillingPortalSessionResponse {
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed error codes returned by the portal-session endpoint when the call
|
||||
* cannot succeed for a reason the UI should explain to the user.
|
||||
*
|
||||
* - `stripe_not_configured` (HTTP 503): Stripe isn't wired up server-side
|
||||
* (rare — env-misconfig / dev mode).
|
||||
* - `no_stripe_customer` (HTTP 400): The account has never been billed, so
|
||||
* there's no Customer Portal session to open. UX: "Complete checkout
|
||||
* first to access billing portal."
|
||||
*/
|
||||
export type BillingPortalErrorCode = 'stripe_not_configured' | 'no_stripe_customer'
|
||||
|
||||
export class BillingPortalError extends Error {
|
||||
code: BillingPortalErrorCode
|
||||
constructor(code: BillingPortalErrorCode, message?: string) {
|
||||
super(message ?? code)
|
||||
this.name = 'BillingPortalError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,14 @@ export type {
|
||||
PlanBillingState,
|
||||
BillingStatePayload,
|
||||
BillingStateApiResponse,
|
||||
CheckoutPlan,
|
||||
BillingInterval,
|
||||
CheckoutSessionRequest,
|
||||
CheckoutSessionResponse,
|
||||
BillingPortalSessionResponse,
|
||||
BillingPortalErrorCode,
|
||||
} from './billing'
|
||||
export { BillingPortalError } from './billing'
|
||||
|
||||
export * from './scripts'
|
||||
export * from './script-builder'
|
||||
|
||||
Reference in New Issue
Block a user