fix(audit-sprint-4b): collapse 4 plan catalogs into one (PLAN_CATALOG) - #166
Conversation
Deep-dive agent inventory uncovered 4 plan catalogs that disagreed on
the key set, prices, and which tiers exist. This PR makes PLAN_CATALOG
in lib/plans.ts the single source of truth. Stays within reviewable
scope by NOT touching the scale-tier product question (flagged below).
What was drifting
1. lib/plans.ts PLAN_CATALOG → keys `basic|pro|scale|enterprise`
2. lib/billing/plans.ts SUBSCRIPTION_PLANS → keys `starter|pro|scale|enterprise`
bridged via `basic→starter` shim in app/api/billing/route.ts:50
3. lib/system-state/types.ts PlanTier → keys `trial|basic|pro|enterprise`
(no scale — silently collapsed any scale-tier org to free trial)
4. components/motion/NodeWireSystem.tsx → 4th local PlanTier declaration
5. lib/marketing/pricing.ts → marketing prices hardcoded ($297/$797/
$1,800), no link to the source-of-truth catalog
Consolidation
- PlanTier is now `PlanKey | 'trial'` (system-state/types.ts).
PLAN_FEATURES gets a scale matrix that inherits Pro + registers/team.
- mapPlanKeyToTier becomes an identity map (was: switch with `default:
return 'trial'` that silently downgraded scale-tier orgs). Test
updated: scale → scale (previously masked by `default` arm).
- NodeWireSystem re-exports PlanTier from system-state. PLAN_HIERARCHY
extended with scale=3 (enterprise=4).
- lib/billing/plans.ts SUBSCRIPTION_PLANS deleted. Runtime resource
limits (members/tasks/storage/certificates/apiCalls) moved into
lib/plans.ts as BILLING_RUNTIME_LIMITS, plus a STRIPE_PRICE_ENV
map and a getBillingPlan(planKey)/getAllBillingPlans() helper
that returns the same shape the billing surface expects.
- app/api/billing/route.ts reads via getBillingPlan(). Legacy
`basic → starter` shim deleted. The default-when-no-plan-recorded
fallback is now 'basic' instead of 'starter' (no behaviour change —
'basic' was already aliased to 'starter' via the shim).
- Marketing: lib/marketing/pricing.ts PUBLIC_PRICING_TIERS lost its
inline name/priceLabel fields. New planKey field points at the
canonical catalog entry; nameFor()/priceLabelFor() helpers derive
display values from PLAN_CATALOG. PricingTiers.tsx wired to the
helpers — first price change in lib/plans.ts now propagates.
- Email templates: app/admin/emails/page.tsx and emails/lifecycle-
emails.tsx no longer hardcode the stale Starter $159 / Professional
$239 / Enterprise $399 prices. Both import PLAN_CATALOG and map
over ['basic','pro','scale']. (lib/email/billing-emails.ts is
owned by Sprint 1 PR #162 — same fix already there; this PR
avoids the duplicate diff that would conflict on merge.)
- Tests dropped: __tests__/lib/billing/plans-consistency.test.ts
and tests/billing/subscription-plans.test.ts (asserted the now-
dropped SUBSCRIPTION_PLANS).
OUT OF SCOPE — surfaced for a separate product decision
- `scale` tier is unsellable today:
• STRIPE_PRICE_SCALE not set in any env
• DB CHECK constraint on org_subscriptions.plan_key rejects 'scale'
(only allows basic|pro|enterprise)
• Live DB has 0 scale-tier rows
Marketing has been advertising the $1,800 tier. Two options:
(a) provision the Stripe price + relax the CHECK + we can sell it,
(b) remove the Scale tier from marketing/code/PlanKey union.
Either option needs a product decision so this PR leaves scale
in place (now correctly typed) and flags it.
- 138 organizations.plan_key=NULL rows — needs backfill decision.
- DB `plans` table (a 5th catalog used only as an FK target) — Sprint 5.
Validation
- tsc -p tsconfig.typecheck.json: clean
- eslint: 0 errors, 18 warnings (baseline)
- jest: 5314/5329 pass (-5 from baseline; -4 from deleted
plans-consistency suite, -1 from deleted subscription-plans suite,
+1 from new scale case in server-branches.test.ts)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Consolidates multiple conflicting “plan catalog” definitions into lib/plans.ts’s PLAN_CATALOG, then re-wires billing, system-state, marketing pricing, and some email templates to derive plan keys/names/prices from that single source of truth.
Changes:
- Derives
PlanTierfromPlanKey(plus'trial') and fixesmapPlanKeyToTierto no longer downgradescale → trial. - Moves billing plan/runtime limits + Stripe env mapping into
lib/plans.ts(getBillingPlan/getAllBillingPlans) and updates/api/billingaccordingly; removeslib/billing/plans.ts. - Updates marketing pricing + trial-expiring email previews/templates to pull names/prices from
PLAN_CATALOG; removes now-obsolete tests tied toSUBSCRIPTION_PLANS.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/billing/subscription-plans.test.ts | Deleted tests that validated SUBSCRIPTION_PLANS Stripe env behavior. |
| lib/system-state/types.ts | PlanTier now derives from `PlanKey |
| lib/system-state/server.ts | mapPlanKeyToTier becomes an identity mapping for resolved plan keys (fixes scale). |
| lib/plans.ts | Adds billing helpers/limits and Stripe env mapping alongside PLAN_CATALOG. |
| lib/marketing/pricing.ts | Pricing display now derives name/price label from PLAN_CATALOG via helpers. |
| lib/billing/plans.ts | Deleted duplicate plan catalog (SUBSCRIPTION_PLANS). |
| emails/lifecycle-emails.tsx | Trial-expiring email now renders plan names/prices from PLAN_CATALOG. |
| components/motion/NodeWireSystem.tsx | Re-exports PlanTier from system-state and adds scale to hierarchy. |
| app/api/billing/route.ts | /api/billing now reads plans via getBillingPlan/getAllBillingPlans. |
| app/admin/emails/page.tsx | Admin email preview now renders plan names/prices from PLAN_CATALOG. |
| app/(marketing)/pricing/components/PricingTiers.tsx | Uses new nameFor / priceLabelFor helpers for display. |
| tests/lib/system-state/server-branches.test.ts | Updates resolvePlanKey mock + adds scale branch case. |
| tests/lib/billing/plans-consistency.test.ts | Deleted catalog-consistency tests tied to SUBSCRIPTION_PLANS. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| {TRIAL_PLAN_ORDER.map((key) => ( | ||
| <Text key={key} style={statRow}> | ||
| {PLAN_CATALOG[key].name} — ${PLAN_CATALOG[key].priceMonthly}/mo | ||
| </Text> | ||
| ))} |
| {TRIAL_PREVIEW_PLANS.map((key) => ( | ||
| <p | ||
| key={key} | ||
| style={{ color: '#e2e8f0', fontSize: 14, margin: '4px 0' }} | ||
| > | ||
| {PLAN_CATALOG[key].name} — ${PLAN_CATALOG[key].priceMonthly}/mo | ||
| </p> |
| export function getBillingPlan(planKey: PlanKey): BillingPlan { | ||
| const config = PLAN_CATALOG[planKey]; | ||
| const envValue = process.env[STRIPE_PRICE_ENV[planKey]]; | ||
| const stripePriceId = envValue && envValue.trim().length > 0 ? envValue.trim() : undefined; | ||
| return { | ||
| id: planKey, | ||
| name: config.name, | ||
| price: config.priceMonthly, | ||
| interval: 'month', | ||
| stripePriceId, | ||
| features: config.features, | ||
| limits: BILLING_RUNTIME_LIMITS[planKey], | ||
| }; | ||
| } | ||
|
|
||
| export function getAllBillingPlans(): BillingPlan[] { | ||
| return (Object.keys(PLAN_CATALOG) as PlanKey[]).map(getBillingPlan); | ||
| } |
🔒 Compliance Testing ResultsTest Date: Sat May 23 14:11:55 UTC 2026 GDPR Compliance❌ FAILED - GDPR compliance issues found SOC2 Compliance❌ FAILED - SOC2 control deficiencies found Compliance Reports: Available in the artifacts section below ✅ COMPLIANCE READY: All tested compliance requirements met. |
♿ Accessibility Test Results✅ PASSED - No critical accessibility issues found Tests Performed:
Artifacts: Download the accessibility reports from the "Artifacts" section for detailed results. |
Audit deep-dive found the Scale tier was advertised in marketing for
$1,800/mo but unsellable end-to-end:
- org_subscriptions_plan_key_check rejected 'scale' (only allowed
basic|pro|enterprise)
- Onboarding plan picker excluded Scale (app/onboarding/page.tsx:54-58)
- .env.example showed STRIPE_PRICE_SCALE as commented-out optional
"comma-separated stripe price IDs" — neither true; check-env.js
already had it in productionRequiredKeys
This PR makes the path actually work end-to-end. The Stripe price ID
itself is operator-set; PR description includes the runbook.
Changes
- Migration 20260624020 drops + recreates
org_subscriptions_plan_key_check with 'scale' added. Comment on the
constraint cross-references the env var.
- app/onboarding/page.tsx: PLAN_CHOICES adds PLAN_CATALOG.scale
between pro and enterprise so new orgs see the Scale tier during
self-serve onboarding.
- .env.example moves STRIPE_PRICE_SCALE into the core STRIPE — BILLING
block (uncommented) with the correct "required in prod" note.
- __tests__/lib/plans.test.ts: expectedPlans was missing 'scale' (the
assertion silently skipped it); now correct. Adds explicit
getBillingPlan('scale') coverage with stripePriceId env resolution
+ getAllBillingPlans('scale' included) assertions.
Built on top of fix/audit-sprint-4b-plan-catalog (PR #166) because
getBillingPlan/getAllBillingPlans live there.
Validation
- tsc -p tsconfig.typecheck.json: clean
- eslint: 0 errors, 17 warnings (same as 4b base)
- jest: 5319/5334 pass (+5 from new scale tests on top of 4b's 5314)
Operator runbook for shipping Scale
1. Create the Scale product + monthly price in Stripe ($1,800 USD).
2. Add STRIPE_PRICE_SCALE=price_... to the Vercel production env.
3. Merge this PR (+ #166 first, since 6a depends on it).
4. Apply migration 20260624020 to prod.
5. Manually flip an existing test org to plan_key='scale' to verify
it doesn't bounce off the CHECK constraint.
6. Watch billing-reconcile cron (PR #162) for any 'scale' drift the
first night.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit deep-dive found the Scale tier was advertised in marketing for
$1,800/mo but unsellable end-to-end:
- org_subscriptions_plan_key_check rejected 'scale' (only allowed
basic|pro|enterprise)
- Onboarding plan picker excluded Scale (app/onboarding/page.tsx:54-58)
- .env.example showed STRIPE_PRICE_SCALE as commented-out optional
"comma-separated stripe price IDs" — neither true; check-env.js
already had it in productionRequiredKeys
This PR makes the path actually work end-to-end. The Stripe price ID
itself is operator-set; PR description includes the runbook.
Changes
- Migration 20260624020 drops + recreates
org_subscriptions_plan_key_check with 'scale' added. Comment on the
constraint cross-references the env var.
- app/onboarding/page.tsx: PLAN_CHOICES adds PLAN_CATALOG.scale
between pro and enterprise so new orgs see the Scale tier during
self-serve onboarding.
- .env.example moves STRIPE_PRICE_SCALE into the core STRIPE — BILLING
block (uncommented) with the correct "required in prod" note.
- __tests__/lib/plans.test.ts: expectedPlans was missing 'scale' (the
assertion silently skipped it); now correct. Adds explicit
getBillingPlan('scale') coverage with stripePriceId env resolution
+ getAllBillingPlans('scale' included) assertions.
Built on top of fix/audit-sprint-4b-plan-catalog (PR #166) because
getBillingPlan/getAllBillingPlans live there.
Validation
- tsc -p tsconfig.typecheck.json: clean
- eslint: 0 errors, 17 warnings (same as 4b base)
- jest: 5319/5334 pass (+5 from new scale tests on top of 4b's 5314)
Operator runbook for shipping Scale
1. Create the Scale product + monthly price in Stripe ($1,800 USD).
2. Add STRIPE_PRICE_SCALE=price_... to the Vercel production env.
3. Merge this PR (+ #166 first, since 6a depends on it).
4. Apply migration 20260624020 to prod.
5. Manually flip an existing test org to plan_key='scale' to verify
it doesn't bounce off the CHECK constraint.
6. Watch billing-reconcile cron (PR #162) for any 'scale' drift the
first night.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Deep-dive agent inventory uncovered 4 plan catalogs in the codebase that disagreed on keys, prices, and tier set. Consolidates them onto
lib/plans.ts'sPLAN_CATALOG. -126 LoC net.The 4 catalogs (before)
lib/plans.tsPLAN_CATALOGlib/billing/plans.tsSUBSCRIPTION_PLANSlib/system-state/types.tsPlanTiercomponents/motion/NodeWireSystem.tsxPlanTierdeclarationlib/marketing/pricing.tsWhat this PR does
PlanTieris nowPlanKey \| 'trial'— derived from PLAN_CATALOG.mapPlanKeyToTierbecomes an identity (was aswitchwhosedefault: return 'trial'silently downgraded scale-tier orgs to free trial).lib/billing/plans.tsDELETED. Runtime resource limits (members/tasks/storage/certs/apiCalls) plus Stripe env-mapping moved tolib/plans.tsasBILLING_RUNTIME_LIMITS+STRIPE_PRICE_ENVwith agetBillingPlan()helper.app/api/billing/route.tsreads through it; legacybasic→startershim gone.nameFor()/priceLabelFor()helpers. Tag fields (badge,audience,ctaHref) stay marketing-owned.app/admin/emails/page.tsxandemails/lifecycle-emails.tsxno longer hardcode the wrong $159/$239/$399 prices. (Same fix inlib/email/billing-emails.tsis owned by Sprint 1 PR fix(audit-sprint-1): 7 stop-the-bleed fixes from 2026-05-23 E2E audit #162 — skipped here to avoid merge conflict.)SUBSCRIPTION_PLANSwhich no longer exists).scaletier is unsellable today — flagged by the deep-dive but explicitly NOT acted on, because either path needs product alignment:STRIPE_PRICE_SCALEenv varorg_subscriptions.plan_keyCHECK constraintTwo options for a follow-up PR:
This PR leaves Scale in place (now correctly typed; before it was silently
→ trialin PlanTier).Also flagged
organizations.plan_key = NULLrows — needs backfill decisionplanstable — a 5th catalog used only as an FK target with stale AUD/USD prices ($159/$239/$399). Sprint 5 deploy hygiene.Validation
npm run type-checkcleannpm run lint0 errors, 18 warnings (baseline)npx jest5314/5329 pass (-5 from baseline: -4 plans-consistency tests, -1 subscription-plans test, +1 newscale → scalecase in server-branches.test.ts)Test plan
$297 / $797 / $1,800strings (now derived fromPLAN_CATALOG)GET /api/billingresponse shape unchanged (currentPlan.{name,price,limits,features,id}+availablePlans[])/admin/emailsshows the correct $297/$797/$1,800 in the trial-expiring template🤖 Generated with Claude Code