Skip to content

fix(audit-sprint-4b): collapse 4 plan catalogs into one (PLAN_CATALOG) - #166

Merged
ejay-dev merged 1 commit into
mainfrom
fix/audit-sprint-4b-plan-catalog
May 23, 2026
Merged

fix(audit-sprint-4b): collapse 4 plan catalogs into one (PLAN_CATALOG)#166
ejay-dev merged 1 commit into
mainfrom
fix/audit-sprint-4b-plan-catalog

Conversation

@ejay-dev

Copy link
Copy Markdown
Owner

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's PLAN_CATALOG. -126 LoC net.

The 4 catalogs (before)

# File Keys Prices
1 lib/plans.ts PLAN_CATALOG basic|pro|scale|enterprise $297/$797/$1800
2 lib/billing/plans.ts SUBSCRIPTION_PLANS starter|pro|scale|enterprise $297/$797/$1800
3 lib/system-state/types.ts PlanTier trial|basic|pro|enterprise (no scale) n/a
4 components/motion/NodeWireSystem.tsx 4th local PlanTier declaration n/a
(+) lib/marketing/pricing.ts display strings, hardcoded $297/$797/$1,800 n/a

What this PR does

  • PlanTier is now PlanKey \| 'trial' — derived from PLAN_CATALOG.
  • mapPlanKeyToTier becomes an identity (was a switch whose default: return 'trial' silently downgraded scale-tier orgs to free trial).
  • NodeWireSystem re-exports PlanTier instead of redeclaring.
  • lib/billing/plans.ts DELETED. Runtime resource limits (members/tasks/storage/certs/apiCalls) plus Stripe env-mapping moved to lib/plans.ts as BILLING_RUNTIME_LIMITS + STRIPE_PRICE_ENV with a getBillingPlan() helper. app/api/billing/route.ts reads through it; legacy basic→starter shim gone.
  • Marketing prices derive from PLAN_CATALOG via nameFor()/priceLabelFor() helpers. Tag fields (badge, audience, ctaHref) stay marketing-owned.
  • Stale email templates fixedapp/admin/emails/page.tsx and emails/lifecycle-emails.tsx no longer hardcode the wrong $159/$239/$399 prices. (Same fix in lib/email/billing-emails.ts is 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.)
  • 2 obsolete test files deleted (asserted SUBSCRIPTION_PLANS which no longer exists).

⚠️ Surfaced for product decision (NOT changed in this PR)

scale tier is unsellable today — flagged by the deep-dive but explicitly NOT acted on, because either path needs product alignment:

Evidence Status
STRIPE_PRICE_SCALE env var Not set in any environment
org_subscriptions.plan_key CHECK constraint Rejects 'scale' (only allows basic|pro|enterprise)
Live DB scale-tier rows 0
Marketing site Actively advertises the $1,800 Scale tier

Two options for a follow-up PR:

  1. Provision the tier: configure Stripe price + relax DB CHECK + we can sell it
  2. Remove the tier: strip from marketing/code/PlanKey union

This PR leaves Scale in place (now correctly typed; before it was silently → trial in PlanTier).

Also flagged

  • 138 organizations.plan_key = NULL rows — needs backfill decision
  • DB plans table — 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-check clean
  • npm run lint 0 errors, 18 warnings (baseline)
  • npx jest 5314/5329 pass (-5 from baseline: -4 plans-consistency tests, -1 subscription-plans test, +1 new scale → scale case in server-branches.test.ts)

Test plan

  • Pricing page renders the same $297 / $797 / $1,800 strings (now derived from PLAN_CATALOG)
  • GET /api/billing response shape unchanged (currentPlan.{name,price,limits,features,id} + availablePlans[])
  • Admin email preview at /admin/emails shows the correct $297/$797/$1,800 in the trial-expiring template
  • React Email render of lifecycle-emails trial template shows the same

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings May 23, 2026 14:07
@vercel

vercel Bot commented May 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
forma-os Ready Ready Preview, Comment May 23, 2026 2:11pm

Request Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 PlanTier from PlanKey (plus 'trial') and fixes mapPlanKeyToTier to no longer downgrade scale → trial.
  • Moves billing plan/runtime limits + Stripe env mapping into lib/plans.ts (getBillingPlan / getAllBillingPlans) and updates /api/billing accordingly; removes lib/billing/plans.ts.
  • Updates marketing pricing + trial-expiring email previews/templates to pull names/prices from PLAN_CATALOG; removes now-obsolete tests tied to SUBSCRIPTION_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.

Comment on lines +309 to +313
{TRIAL_PLAN_ORDER.map((key) => (
<Text key={key} style={statRow}>
{PLAN_CATALOG[key].name} — ${PLAN_CATALOG[key].priceMonthly}/mo
</Text>
))}
Comment thread app/admin/emails/page.tsx
Comment on lines +220 to +226
{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>
Comment thread lib/plans.ts
Comment on lines +174 to +191
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);
}
@github-actions

Copy link
Copy Markdown

🔒 Compliance Testing Results

Test Date: Sat May 23 14:11:55 UTC 2026
Test Type: all

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.

@github-actions

Copy link
Copy Markdown

♿ Accessibility Test Results

PASSED - No critical accessibility issues found

Tests Performed:

  • WCAG 2.1 AA compliance validation
  • Cross-browser accessibility testing
  • Keyboard navigation testing
  • Screen reader compatibility
  • Color contrast validation

Artifacts: Download the accessibility reports from the "Artifacts" section for detailed results.

@ejay-dev
ejay-dev merged commit 77aeaa3 into main May 23, 2026
31 of 34 checks passed
@ejay-dev
ejay-dev deleted the fix/audit-sprint-4b-plan-catalog branch May 23, 2026 16:52
ejay-dev added a commit that referenced this pull request May 23, 2026
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>
ejay-dev added a commit that referenced this pull request May 23, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants