Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
61 changes: 60 additions & 1 deletion __tests__/lib/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

import {
PLAN_CATALOG,
getAllBillingPlans,
getBillingPlan,
isPlanKey,
resolvePlanKey,
type PlanKey,
Expand All @@ -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) {
Expand Down Expand Up @@ -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');
});

Expand All @@ -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']);
});
});
7 changes: 7 additions & 0 deletions app/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Update plan validation to accept the newly added scale tier

Adding PLAN_CATALOG.scale to onboarding choices makes users able to select Scale, but step 2 submission still calls validatePlan(planCandidate) in saveOrgDetails, and validatePlan only accepts basic|pro|enterprise (lib/validators/organization.ts PLAN_OPTIONS). In practice, choosing Scale will be rejected as invalid and redirect back with error=1, so the tier remains unsellable through onboarding despite the UI change.

Useful? React with 👍 / 👎.

PLAN_CATALOG.enterprise,
];
Comment on lines +54 to 65

Expand Down
Original file line number Diff line number Diff line change
@@ -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).
Comment on lines +10 to +12
-- 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.
Comment on lines +16 to +18

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.';