Skip to content
Merged
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
63 changes: 0 additions & 63 deletions __tests__/lib/billing/plans-consistency.test.ts

This file was deleted.

14 changes: 13 additions & 1 deletion __tests__/lib/system-state/server-branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,18 @@ jest.mock('@/lib/supabase/server', () => ({
createSupabaseServerClient: jest.fn(async () => __server),
}));

// Audit 2026-05-23: previously this mock was identity (`(key) => key`),
// which masked a bug in mapPlanKeyToTier — the real code now returns
// the resolved key directly (was: `switch { default: return 'trial' }`),
// so the mock must mirror real resolvePlanKey behaviour or 'unknown'
// returns 'unknown' instead of falling through to the trial default.
jest.mock('@/lib/plans', () => ({
resolvePlanKey: jest.fn((key: string | null) => key),
resolvePlanKey: jest.fn((key: string | null | undefined) => {
if (!key) return null;
const valid = ['basic', 'pro', 'scale', 'enterprise'];
const lower = key.toLowerCase();
return valid.includes(lower) ? lower : null;
}),
}));

jest.mock('@/app/app/actions/rbac', () => ({
Expand Down Expand Up @@ -100,6 +110,8 @@ describe('mapPlanKeyToTier (branches)', () => {
it.each([
['basic', 'basic'],
['pro', 'pro'],
// Audit 2026-05-23: was silently → 'trial' under the prior switch default.
['scale', 'scale'],
['enterprise', 'enterprise'],
[null, 'trial'],
[undefined, 'trial'],
Expand Down
10 changes: 7 additions & 3 deletions app/(marketing)/pricing/components/PricingTiers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import Link from 'next/link';
import { ArrowRight, CheckCircle2 } from 'lucide-react';
import { motion, useReducedMotion } from 'framer-motion';
import { PUBLIC_PRICING_TIERS } from '@/lib/marketing/pricing';
import {
PUBLIC_PRICING_TIERS,
nameFor,
priceLabelFor,
} from '@/lib/marketing/pricing';
import { useMarketingTelemetry } from '@/lib/marketing/marketing-telemetry';
import { ScrollReveal } from '@/components/motion/ScrollReveal';
import { SectionChoreography } from '@/components/motion/SectionChoreography';
Expand Down Expand Up @@ -148,7 +152,7 @@ export function PricingTiers() {
{/* Body */}
<div className="flex flex-1 flex-col px-6 pt-6 pb-6">
<h3 className="text-2xl font-semibold tracking-tight text-white">
{tier.name}
{nameFor(tier)}
</h3>
<p className="mt-1.5 text-[13px] leading-snug text-slate-400">
{tier.audience}
Expand All @@ -160,7 +164,7 @@ export function PricingTiers() {
{/* Price */}
<div className="mt-7 flex items-end gap-2">
<span className="font-mono text-5xl font-semibold tracking-tight text-white">
{tier.priceLabel}
{priceLabelFor(tier)}
</span>
<span className="pb-2 text-sm font-medium text-slate-400">
{tier.priceSubtext}
Expand Down
23 changes: 14 additions & 9 deletions app/admin/emails/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import React, { useState } from 'react';
import { PLAN_CATALOG, type PlanKey } from '@/lib/plans';

/**
* Admin Email Preview Page
Expand All @@ -12,6 +13,11 @@ import React, { useState } from 'react';
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? 'https://app.formaos.com.au';
const _BASE = APP_URL.replace(/\/$/, '');

// Audit 2026-05-23: previously hardcoded Starter $159 / Professional $239
// / Enterprise $399. Source from PLAN_CATALOG so the admin preview matches
// reality (and the outbound emails it mirrors).
const TRIAL_PREVIEW_PLANS: readonly PlanKey[] = ['basic', 'pro', 'scale'];

// Shared email styles (mirrors the actual templates)
const main = {
backgroundColor: '#0f172a',
Expand Down Expand Up @@ -211,15 +217,14 @@ const templates: TemplateConfig[] = [
margin: '20px 0',
}}
>
<p style={{ color: '#e2e8f0', fontSize: 14, margin: '4px 0' }}>
Starter — $159/mo
</p>
<p style={{ color: '#e2e8f0', fontSize: 14, margin: '4px 0' }}>
Professional — $239/mo
</p>
<p style={{ color: '#e2e8f0', fontSize: 14, margin: '4px 0' }}>
Enterprise — $399/mo
</p>
{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 on lines +220 to +226
))}
</div>
<div style={{ textAlign: 'center', margin: '24px 0' }}>
<a
Expand Down
26 changes: 16 additions & 10 deletions app/api/billing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { createSupabaseServerClient } from '@/lib/supabase/server';
import { rateLimitApi } from '@/lib/security/rate-limiter';
import { routeLog } from '@/lib/monitoring/server-logger';
import { captureRouteError } from '@/lib/observability/with-route-observability';
import { SUBSCRIPTION_PLANS } from '@/lib/billing/plans';
import {
getAllBillingPlans,
getBillingPlan,
resolvePlanKey,
} from '@/lib/plans';

const log = routeLog('/api/billing');

Expand Down Expand Up @@ -42,14 +46,16 @@ export async function GET(request: Request) {
.eq('organization_id', orgId)
.maybeSingle();

// Default to 'starter' (Foundation) when no plan is recorded — the 'free'
// tier was removed (High-9). Self-serve users land in pending_checkout on
// 'starter' until they complete Stripe Checkout; the layout-level gate at
// app/app/layout.tsx blocks /app/* access until status is 'active'.
const planKey = (subscription?.plan_key as string | undefined) || 'starter';
const legacyTier = planKey === 'basic' ? 'starter' : planKey;
const currentPlan = SUBSCRIPTION_PLANS[legacyTier as keyof typeof SUBSCRIPTION_PLANS]
?? SUBSCRIPTION_PLANS.starter;
// Default to Foundation (basic) when no plan is recorded — the 'free'
// tier was removed (High-9). Self-serve users land in pending_checkout
// on 'basic' until they complete Stripe Checkout; the layout-level
// gate at app/app/layout.tsx blocks /app/* access until status is
// 'active'.
// Audit Sprint 4b: route now reads from the canonical PLAN_CATALOG via
// getBillingPlan(). The legacy 'basic → starter' shim is gone because
// SUBSCRIPTION_PLANS no longer exists.
const planKey = resolvePlanKey(subscription?.plan_key as string | null) ?? 'basic';
const currentPlan = getBillingPlan(planKey);

const [membersCount, tasksCount, certsCount, evidenceCount] = await Promise.all([
supabase.from('org_members').select('id', { count: 'exact', head: true }).eq('organization_id', orgId),
Expand All @@ -73,7 +79,7 @@ export async function GET(request: Request) {
currentPlan,
usage,
limits: currentPlan.limits,
availablePlans: Object.values(SUBSCRIPTION_PLANS),
availablePlans: getAllBillingPlans(),
subscriptionStatus: subscription?.status ?? 'inactive',
currentPeriodEnd: subscription?.current_period_end ?? null,
cancelAt: subscription?.cancel_at ?? null,
Expand Down
11 changes: 8 additions & 3 deletions components/motion/NodeWireSystem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ import {
* - Feature access logic
*/

export type PlanTier = 'trial' | 'basic' | 'pro' | 'enterprise';
// Audit 2026-05-23: was a 4th declaration of PlanTier (drift risk). Now
// re-exports from the system-state source of truth so adding/removing
// plan tiers in one place updates everything.
import type { PlanTier as SystemPlanTier } from '@/lib/system-state/types';
export type PlanTier = SystemPlanTier;
export type UserRole = 'viewer' | 'member' | 'admin' | 'owner';
export type NodeStatus =
| 'active'
Expand Down Expand Up @@ -133,12 +137,13 @@ const MODULES: ModuleNode[] = [
},
];

// Plan hierarchy for access control
// Plan hierarchy for access control. scale sits between pro and enterprise.
const PLAN_HIERARCHY: Record<PlanTier, number> = {
trial: 0,
basic: 1,
pro: 2,
enterprise: 3,
scale: 3,
enterprise: 4,
};

function getNodeStatus(
Expand Down
13 changes: 10 additions & 3 deletions emails/lifecycle-emails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ import {
Hr,
} from '@react-email/components';
import * as React from 'react';
import { PLAN_CATALOG, type PlanKey } from '@/lib/plans';

const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? 'https://app.formaos.com.au';

// Audit 2026-05-23: previously hardcoded $159/$239/$399 in the trial
// stat box. Source from PLAN_CATALOG.
const TRIAL_PLAN_ORDER: readonly PlanKey[] = ['basic', 'pro', 'scale'];
const BASE = APP_URL.replace(/\/$/, '');

// Shared styles
Expand Down Expand Up @@ -301,9 +306,11 @@ export function TrialExpiringEmail({
Upgrade to keep everything.
</Text>
<Section style={statBox}>
<Text style={statRow}>Starter — $159/mo</Text>
<Text style={statRow}>Professional — $239/mo</Text>
<Text style={statRow}>Enterprise — $399/mo</Text>
{TRIAL_PLAN_ORDER.map((key) => (
<Text key={key} style={statRow}>
{PLAN_CATALOG[key].name} — ${PLAN_CATALOG[key].priceMonthly}/mo
</Text>
))}
Comment on lines +309 to +313
</Section>
<Section style={{ textAlign: 'center' as const, margin: '24px 0' }}>
<Button href={`${BASE}/app/billing`} style={cta}>
Expand Down
Loading
Loading