feat(billing): plan taxonomy reconciliation + Stripe sync + internal-tester allowlist (#164)
All checks were successful
CI / frontend (push) Successful in 6m40s
Mirror to GitHub / mirror (push) Successful in 7s
CI / e2e (push) Successful in 10m7s
CI / backend (push) Successful in 10m34s

Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
This commit was merged in pull request #164.
This commit is contained in:
2026-05-11 05:07:07 +00:00
committed by chihlasm
parent dad5e1f546
commit 3f04911070
38 changed files with 745 additions and 110 deletions

View File

@@ -13,6 +13,54 @@
---
## 2026-05-07 — Per-email allowlist (`INTERNAL_TESTER_EMAILS`) for self-serve soft cutover
**Context:** Phase O Task 46 ("internal validation pass") needed a way to exercise the full self-serve flow against the prod backend before flipping `SELF_SERVE_ENABLED=true` for everyone. The plan doc described the mechanism but the backend support was never built — flagged in `SESSION_LOG.md` as a code blocker. Stripe live-mode setup is also gated on having a working internal-tester path in prod test mode.
**Decision:** Comma-separated allowlist `INTERNAL_TESTER_EMAILS` parsed by a Pydantic field_validator into a normalized lowercase list. Two helpers on `Settings`: `is_internal_tester(email)` (case-insensitive membership check) and `is_self_serve_active_for(email)` (returns `SELF_SERVE_ENABLED OR is_internal_tester(email)`). Both endpoints that gate on the global flag now call the helper:
- `/config/public` accepts optional auth via new `get_current_user_optional` dep; returns `self_serve_enabled=true` for allowlisted authenticated callers; anonymous calls always see the global flag.
- `/auth/register` allows allowlisted emails to register without an invite code.
**Rejected:**
- **Custom header `X-Internal-Tester-Email` for anonymous flows.** Spoofable. The auth/register-payload checks are sufficient because the user has to OWN the email to register or log in.
- **Separate allowlists per surface (`INTERNAL_PRICING_TESTERS`, `INTERNAL_OAUTH_TESTERS`).** Premature splitting. The Phase O use case is "this small set of people can see the new flow"; one variable handles it. If finer granularity emerges, split then.
- **Database table for the allowlist.** Env var matches the spec from the plan doc and fits the soft-cutover lifecycle — list is small, changes infrequently, lives alongside other deployment-time config.
**Consequences:**
- Stripe internal validation can run end-to-end in prod test mode without flipping the global flag.
- Anonymous callers always see the global flag — the allowlist never leaks via unauthenticated request content. Three regression tests in `test_config_public.py` enforce this.
- `INTERNAL_TESTER_EMAILS` plumbed through `docker-compose.dev.yml` and documented in `backend/.env.example`. Railway prod env will need the same var set during Phase O cutover.
---
## 2026-05-07 — Reconcile plan tier taxonomy (rename `team` → `enterprise`, add `starter`)
**Context:** PR #162 left a real architectural gap. Marketing surface (PricingPage, Stripe products) was wired for `Starter / Pro / Enterprise` while backend was on `free / pro / team`. `plan_billing.plan` FK referenced `plan_limits.plan` so the `BillingPlan` schema's `Literal["pro", "starter", "team", "enterprise"]` could accept values that violated the FK. `plan_billing` was unseeded in dev, so no checkout could complete. `Subscription.plan.in_(["pro", "team"])` paid-plan checks wouldn't recognize `enterprise`. Self-serve cutover was blocked at the data layer.
**Decision:** Reconcile to a single taxonomy — backend slugs become `free / pro / starter / enterprise`, matching the marketing surface and Stripe products. Migration `4ce3e594cb87`:
1. Defensive `UPDATE subscriptions SET plan='enterprise' WHERE plan='team'` (dev had zero such rows; safety for any prod stragglers).
2. Rename the `plan_limits.plan='team'` row to `'enterprise'`.
3. Insert a `starter` row with caps interpolated between free and pro: `max_trees=10`, `max_sessions=75`, `max_users=1`, `max_ai_builds_per_month=15`, no KB Accelerator, no custom branding, no priority support.
Code rename across schemas, `Subscription` paid-plan/`has_pro_entitlement` checks, admin endpoints, frontend `useSubscription.isPaidPlan`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched — that string means "shared with my account" and has nothing to do with the subscription tier.
New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert of `plan_billing` rows from Stripe products by exact name match (`ResolutionFlow Starter / Pro / Enterprise`). Picks the active monthly recurring price for tiers that have one. Annual fields stay NULL by design — annual pricing is intentionally out of scope for the soft cutover ("want to be able to exit if necessary without breaching any terms").
**Rejected:**
- **Map marketing names to existing slugs (Option A from the discussion).** Smallest diff but means PricingPage cards have to translate `enterprise``team` at render time, and "Starter" can't exist as a real backend tier — it'd have to be hidden or dropped. Kicks the can.
- **Add `starter` only, keep `team` slug as cosmetic enterprise (Option C).** Mixed taxonomy across layers — slug-vs-display-name divergence guarantees confusion in 6 months. Compromise that's worse than either pure choice.
- **Annual pricing in this iteration.** User's explicit constraint: skip annual to keep exit-flexibility. Schema columns (`annual_price_cents`, `stripe_annual_price_id`) preserved as nullable for future re-enable.
- **Auto-archive the existing Enterprise `$500/mo` test-mode price.** Done manually via Stripe MCP after un-setting the product's `default_price` first. Spec says Enterprise is sales-led with no catalog price.
**Consequences:**
- `plan_billing` table is now seedable and seeded. Test-mode `plan_billing` populated for all 3 tiers via `sync_stripe_plan_ids.py`. Live mode runs the same script after manual Dashboard setup of products + prices.
- New consumers of `Subscription.plan` literal must use `("free", "pro", "starter", "enterprise")`. Three call sites already updated. Backend-wide grep is the safety net for new ones.
- `Subscription.is_paid` and `has_pro_entitlement` now include `starter` — Starter is a paid tier with a real $19.99/mo price.
- 86/86 passing across the subscription/billing/plan/invite/admin sweep after the rename.
- Test fixtures: `conftest.py` plan_limits seed updated to the new taxonomy. `_seed_plan_limits` helper in `test_plans_public.py` is now a true upsert so tests can override `max_users` even when conftest seeded the canonical value.
---
## 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.