From 614c1e4f0bcfafc3156847e79995177b58a03075 Mon Sep 17 00:00:00 2001 From: ejay-dev Date: Sun, 24 May 2026 01:08:33 +0930 Subject: [PATCH] fix(audit-sprint-6a): make the Scale tier sellable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 4 +- __tests__/lib/plans.test.ts | 61 ++++++++++++++++++- app/onboarding/page.tsx | 7 +++ ...624020_audit_sprint6a_scale_tier_check.sql | 31 ++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 supabase/migrations/20260624020_audit_sprint6a_scale_tier_check.sql diff --git a/.env.example b/.env.example index 4c4e07ce9..e4da77c8c 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,7 @@ STRIPE_SECRET_KEY=sk_test_REPLACE_ME # [SECRET] St STRIPE_WEBHOOK_SECRET=whsec_REPLACE_ME # [SECRET] Stripe webhook signing secret (Developers → Webhooks) STRIPE_PRICE_FOUNDATION=price_REPLACE_ME # [SECRET] Foundation price ID (maps to internal basic) STRIPE_PRICE_GROWTH=price_REPLACE_ME # [SECRET] Growth price ID (maps to internal pro) +STRIPE_PRICE_SCALE=price_REPLACE_ME # [SECRET] Scale price ID (maps to internal scale, $1,800/mo). Required in prod (scripts/check-env.js productionRequiredKeys). Without it the Scale tier checkout will fail at price-id resolution. # STRIPE_PRICE_ENTERPRISE=price_REPLACE_ME # [SECRET] optional — Price ID for enterprise plan # Stripe Payment Links are sales-only. Both Foundation and Growth links are sent manually by sales # post-demo to buyers who already have a FormaOS account (so the webhook can provision via @@ -189,7 +190,8 @@ CRON_SECRET=REPLACE_ME # [SECRET] be # INTEGRATION_CONFIG_SECRET=REPLACE_ME # [SECRET] required in prod — AES-256-GCM key for directory_sync_configs.config + integration credentials # TRUST_PACKET_SIGNING_KEY=REPLACE_ME # [SECRET] required in prod — Ed25519 signing key for trust packet exports # EMAIL_UNSUBSCRIBE_SECRET=REPLACE_ME # [SECRET] required in prod — signs one-click unsubscribe tokens in outgoing emails -# STRIPE_PRICE_SCALE= # [SECRET] optional — comma-separated stripe price IDs for scale plan tiers +# Audit Sprint 6a (2026-05-23): STRIPE_PRICE_SCALE moved to the core +# STRIPE — BILLING block (uncommented) since Scale is now a sellable tier. # SAML_SP_PRIVATE_KEY=REPLACE_ME # [SECRET] required for SAML SSO — service-provider private key (PEM, base64) # SAML_SP_PUBLIC_CERT=REPLACE_ME # [SECRET] required for SAML SSO — service-provider public certificate (PEM, base64) # VAPID_PRIVATE_KEY=REPLACE_ME # [SECRET] required for push notifications — VAPID private key for web push diff --git a/__tests__/lib/plans.test.ts b/__tests__/lib/plans.test.ts index 2c8cd1fbc..6beda09f2 100644 --- a/__tests__/lib/plans.test.ts +++ b/__tests__/lib/plans.test.ts @@ -9,6 +9,8 @@ import { PLAN_CATALOG, + getAllBillingPlans, + getBillingPlan, isPlanKey, resolvePlanKey, type PlanKey, @@ -20,7 +22,9 @@ import { // ------------------------------------------------------------------------- describe('PLAN_CATALOG', () => { - const expectedPlans: PlanKey[] = ['basic', 'pro', 'enterprise']; + // Audit Sprint 6a (2026-05-23): scale was missing from this list while + // PLAN_CATALOG.scale already existed — the assertion silently skipped it. + const expectedPlans: PlanKey[] = ['basic', 'pro', 'scale', 'enterprise']; it('contains all expected plan keys', () => { for (const plan of expectedPlans) { @@ -131,6 +135,7 @@ describe('resolvePlanKey', () => { it('returns the same key for valid plan keys', () => { expect(resolvePlanKey('basic')).toBe('basic'); expect(resolvePlanKey('pro')).toBe('pro'); + expect(resolvePlanKey('scale')).toBe('scale'); expect(resolvePlanKey('enterprise')).toBe('enterprise'); }); @@ -152,6 +157,60 @@ describe('resolvePlanKey', () => { it('normalizes case to lowercase', () => { expect(resolvePlanKey('Basic')).toBe('basic'); expect(resolvePlanKey('PRO')).toBe('pro'); + expect(resolvePlanKey('Scale')).toBe('scale'); expect(resolvePlanKey('Enterprise')).toBe('enterprise'); }); }); + +// ------------------------------------------------------------------------- +// getBillingPlan / getAllBillingPlans (Sprint 4b helpers, exercised by +// app/api/billing/route.ts). Sprint 6a adds explicit scale coverage so +// the tier-provisioning regression bites here first if it ever drifts. +// ------------------------------------------------------------------------- + +describe('getBillingPlan', () => { + it('returns Foundation shape for basic', () => { + const plan = getBillingPlan('basic'); + expect(plan.id).toBe('basic'); + expect(plan.name).toBe('Foundation'); + expect(plan.price).toBe(297); + expect(plan.interval).toBe('month'); + expect(plan.limits.members).toBe(10); + }); + + it('returns Scale shape with $1,800 monthly', () => { + const plan = getBillingPlan('scale'); + expect(plan.id).toBe('scale'); + expect(plan.name).toBe('Scale'); + expect(plan.price).toBe(1800); + expect(plan.limits.members).toBe(75); + }); + + it('resolves stripePriceId from STRIPE_PRICE_SCALE env when set', () => { + const original = process.env.STRIPE_PRICE_SCALE; + process.env.STRIPE_PRICE_SCALE = 'price_scale_test_123'; + try { + expect(getBillingPlan('scale').stripePriceId).toBe('price_scale_test_123'); + } finally { + if (original === undefined) delete process.env.STRIPE_PRICE_SCALE; + else process.env.STRIPE_PRICE_SCALE = original; + } + }); + + it('returns undefined stripePriceId when env not set', () => { + const original = process.env.STRIPE_PRICE_SCALE; + delete process.env.STRIPE_PRICE_SCALE; + try { + expect(getBillingPlan('scale').stripePriceId).toBeUndefined(); + } finally { + if (original !== undefined) process.env.STRIPE_PRICE_SCALE = original; + } + }); +}); + +describe('getAllBillingPlans', () => { + it('returns every PlanKey including scale', () => { + const ids = getAllBillingPlans().map((p) => p.id).sort(); + expect(ids).toEqual(['basic', 'enterprise', 'pro', 'scale']); + }); +}); diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx index 2c325f72f..65fe4e7b2 100644 --- a/app/onboarding/page.tsx +++ b/app/onboarding/page.tsx @@ -51,9 +51,16 @@ import { trackActivation } from '@/lib/analytics/activation-telemetry'; export const dynamic = 'force-dynamic'; const TOTAL_STEPS = 7; +// Audit Sprint 6a (2026-05-23): Scale tier added. Was deliberately +// excluded because the DB CHECK constraint on org_subscriptions +// rejected 'scale'; that's fixed by migration 20260624020. Operator +// must also set STRIPE_PRICE_SCALE in production env for checkout to +// resolve a price ID — productionRequiredKeys in check-env.js already +// guards this. const PLAN_CHOICES = [ PLAN_CATALOG.basic, PLAN_CATALOG.pro, + PLAN_CATALOG.scale, PLAN_CATALOG.enterprise, ]; diff --git a/supabase/migrations/20260624020_audit_sprint6a_scale_tier_check.sql b/supabase/migrations/20260624020_audit_sprint6a_scale_tier_check.sql new file mode 100644 index 000000000..1d8ef5dd4 --- /dev/null +++ b/supabase/migrations/20260624020_audit_sprint6a_scale_tier_check.sql @@ -0,0 +1,31 @@ +-- Sprint 6a — make the Scale tier sellable. +-- +-- The 2026-05-23 audit deep-dive caught that `scale` was advertised in +-- marketing for $1,800/mo but unsellable at the DB level: the +-- org_subscriptions_plan_key_check CHECK constraint rejected any row +-- with plan_key='scale'. End-to-end: +-- +-- 1. STRIPE_PRICE_SCALE was already in productionRequiredKeys +-- (scripts/check-env.js:65) — env scaffolding ready. +-- 2. STRIPE_PRICE_ENV['scale'] = 'STRIPE_PRICE_FOUNDATION'... wait +-- no, that's basic. Scale maps to STRIPE_PRICE_SCALE (lib/plans.ts +-- already correct). +-- 3. PLAN_CATALOG.scale exists with $1,800 monthly (lib/plans.ts). +-- 4. The DB CHECK rejected it. THIS migration fixes that. +-- +-- Also tightens documentation: the existing comment on the constraint +-- (added by 20260616_org_subscriptions_plan_key_check.sql) implied the +-- catalog was basic|pro|enterprise. After this migration that's wrong. + +ALTER TABLE public.org_subscriptions + DROP CONSTRAINT IF EXISTS org_subscriptions_plan_key_check; + +ALTER TABLE public.org_subscriptions + ADD CONSTRAINT org_subscriptions_plan_key_check + CHECK (plan_key = ANY (ARRAY['basic'::text, 'pro'::text, 'scale'::text, 'enterprise'::text])); + +COMMENT ON CONSTRAINT org_subscriptions_plan_key_check ON public.org_subscriptions IS + 'Audit Sprint 6a (2026-05-23): scale tier added so the marketed ' + '$1,800/mo Scale plan is actually writable. Requires ' + 'STRIPE_PRICE_SCALE env var set in production — see scripts/check-env.js ' + 'productionRequiredKeys.';