diff --git a/app.js b/app.js
index 4d7172c..a197cde 100644
--- a/app.js
+++ b/app.js
@@ -5,6 +5,11 @@ import {
openModal,
setModalPending,
} from "./src/sharedControls.js";
+import {
+ PLAN_LABELS as planLabels,
+ PLAN_LIMITS as planLimits,
+ PLAN_FEATURES as planFeatures,
+} from "./src/plansSurface.js";
import {
getPrimaryArtifact,
normalizeArtifactBundle,
@@ -139,11 +144,9 @@ let identityState = {
}
// --- TIER LIMITS ---
-const TIER_LIMITS = {
- free: 2,
- professional: 50,
- power: 2000,
-}
+// Single source of truth in src/plansSurface.js so the plans page, the
+// paywall and generation gating agree (STU-382 criterion 1).
+const TIER_LIMITS = planLimits;
const SUBSCRIPTION_CACHE_KEY = 'ccc_subscription'
const SUBSCRIPTION_CACHE_VERSION = 3
@@ -4686,7 +4689,7 @@ function renderUsageSurfaces() {
const limit = resolved ? getRunLimit() : 0;
const remaining = Math.max(0, limit - count);
const usedLimitText = `${count} / ${limit} runs this month`;
- const labels = { free: "Free", professional: "Pro", power: "Power" };
+ const labels = planLabels;
const tier = resolved ? (subscriptionState.tier || "free") : null;
const topbarCredits = document.getElementById("topbar-credits-count");
@@ -4992,17 +4995,43 @@ async function callBuildShip(step, model, prompt, context = {}, images = []) {
}
}
+// Set when the user returns from Stripe with `?checkout=success`. The paid
+// entitlement is never granted by the return param itself — it is only ever
+// set by the subscription reconciliation below. This flag defers the "plan
+// active" confirmation until after reconcileSubscription has confirmed a paid
+// tier, so a return that has NOT reconciled (blocked, webhook lag, or a
+// cancelled/failed session) can never be reported as subscribed (STU-382
+// criterion 3).
+let checkoutConfirmPending = false
+
function handleCheckoutRedirect() {
const params = new URLSearchParams(window.location.search)
const checkout = params.get('checkout')
if (checkout === 'success') {
window.history.replaceState({}, '', window.location.pathname + window.location.hash)
clearSubscriptionCache()
- showToast('Subscription active! Welcome aboard.', 'success')
+ checkoutConfirmPending = true
} else if (checkout === 'cancel') {
window.history.replaceState({}, '', window.location.pathname + window.location.hash)
showToast('Checkout cancelled.', 'info')
}
+ return checkout
+}
+
+// Called once the subscription has been reconciled after a checkout return.
+// Only reports a live plan when the contract is actually paid; otherwise it
+// says the subscription is still being confirmed so the UI never claims a
+// subscribed state it cannot back up.
+function confirmCheckoutAfterReconcile() {
+ if (!checkoutConfirmPending) return
+ checkoutConfirmPending = false
+ const isPaid = subscriptionState.tier !== 'free'
+ showToast(
+ isPaid
+ ? 'Subscription active! Your plan is live.'
+ : 'Checkout complete — confirming your subscription. Check Manage billing if it does not update.',
+ isPaid ? 'success' : 'info',
+ )
}
// --- SUBSCRIPTION UI ---
@@ -5015,7 +5044,7 @@ function updateSubscriptionUI() {
const badge = document.getElementById('subscription-tier-badge')
if (badge) {
- const labels = { free: 'Free', professional: 'Professional', power: 'Power Developer' }
+ const labels = planLabels
const colors = {
free: 'bg-gray-100 text-gray-600',
professional: 'bg-indigo-100 text-indigo-700',
@@ -5062,6 +5091,27 @@ function updatePricingModalState(tier) {
if (freeCurrent) freeCurrent.classList.toggle('hidden', tier !== 'free')
}
+// Render every plan card's feature list from the single PLAN_FEATURES source,
+// so the plans page and the pricing modal share one feature set and can never
+// drift (and a test can never assert a constant the UI does not render). Each
+// .pm-card carries data-tier; its
is filled from
+// PLAN_FEATURES[tier]. Idempotent: safe to run more than once.
+function renderPlanFeatureLists() {
+ document.querySelectorAll('.pm-card[data-tier]').forEach((card) => {
+ const tier = card.dataset.tier
+ const rows = planFeatures[tier]
+ if (!rows) return
+ const ul = card.querySelector('ul.pm-features')
+ if (!ul) return
+ ul.innerHTML = ''
+ for (const row of rows) {
+ const li = document.createElement('li')
+ li.textContent = row
+ ul.appendChild(li)
+ }
+ })
+}
+
function updatePricingDisplay() {
const currency = detectUserCurrency()
const proEl = document.getElementById('pro-price')
@@ -5130,8 +5180,10 @@ document.addEventListener("DOMContentLoaded", async () => {
await initializeAuth();
handleCheckoutRedirect();
await fetchSubscription();
+ confirmCheckoutAfterReconcile();
updateSubscriptionUI();
updatePricingDisplay();
+ renderPlanFeatureLists();
// Initialize API keys and check connection
await checkConnection();
@@ -6623,7 +6675,7 @@ function updateShellUI() {
const loading = signedIn && isSubscriptionLoading();
const resolved = !signedIn || isSubscriptionResolved();
- const labels = { free: "Free", professional: "Pro", power: "Power" };
+ const labels = planLabels;
const planLabel = loading ? "Checking…" : resolved ? labels[tier] || "Free" : "—";
const topbarPlan = document.getElementById("topbar-plan");
diff --git a/e2e/smoke.spec.js b/e2e/smoke.spec.js
index 21ba488..14a8964 100644
--- a/e2e/smoke.spec.js
+++ b/e2e/smoke.spec.js
@@ -126,7 +126,7 @@ test.describe("Redesigned hero landing and composer", () => {
await page.goto("/#account");
const badge = page.locator("#subscription-tier-badge");
- await expect(badge).toContainText("Professional");
+ await expect(badge).toContainText("Pro");
});
test("signed-in Power badge is rendered from fixture", async ({ page }) => {
@@ -149,7 +149,7 @@ test.describe("Redesigned hero landing and composer", () => {
await page.goto("/#account");
const badge = page.locator("#subscription-tier-badge");
- await expect(badge).toContainText("Power Developer");
+ await expect(badge).toContainText("Power");
});
test("unresolved subscription surfaces the unavailable plan state", async ({ page }) => {
diff --git a/e2e/stu382-plans-paywall.spec.js b/e2e/stu382-plans-paywall.spec.js
new file mode 100644
index 0000000..32f9d72
--- /dev/null
+++ b/e2e/stu382-plans-paywall.spec.js
@@ -0,0 +1,328 @@
+import { test, expect } from "@playwright/test";
+import {
+ applyDefaultRoutes,
+ guestIdentity,
+ freeSubscription,
+ professionalSubscription,
+ unresolvedSubscription,
+ ENDPOINTS,
+ checkoutSession,
+} from "./fixtures/apiFixtures.js";
+
+/**
+ * STU-382 — the plans surface and paywall states must be backed by real
+ * subscription contracts.
+ *
+ * Covered criteria:
+ * - C1: Free/Pro/Power labels and current-plan indicators agree across the
+ * topbar, the account badge, the plans page and model gating.
+ * - C3: a cancelled, blocked or un-reconciled checkout return is never
+ * reported as subscribed; a return whose contract has reconciled refreshes
+ * allowance and unlocks available models.
+ * - C4: the shipped surface advertises no simulated purchase success (the
+ * design's mock "Subscription confirmed / no charge was made" dialogs are
+ * absent), and an upgrade click goes to the real Stripe checkout endpoint.
+ * - C5: the plan comparison cards carry only capabilities the product
+ * actually delivers. "API & MCP access" and "early access" both ship, so
+ * they appear as available rows ("API & MCP access (contact us)" names its
+ * non-self-serve access path); no row may read as not-yet-available.
+ *
+ * A checkout return alone never grants a paid tier. Entitlement only ever
+ * comes from the get-subscription reconciliation fixture, so "?checkout=X"
+ * with a FREE contract must still render Free everywhere.
+ */
+
+const currentMonth = () => new Date().toISOString().slice(0, 7);
+
+function identityWithUsage({ count = 0, status = "recognized" } = {}) {
+ return {
+ status: 200,
+ body: JSON.stringify({
+ status,
+ user_id: "stu382-0001",
+ identity_token: "identity-token",
+ usage_count: count,
+ usage_month: currentMonth(),
+ }),
+ contentType: "application/json",
+ };
+}
+
+async function seedSession(page) {
+ await page.addInitScript(() => {
+ localStorage.setItem(
+ "ccc_auth_session",
+ JSON.stringify({ email: "plans@example.com", sessionToken: "session-token" }),
+ );
+ });
+}
+
+const proModelOption = (page) =>
+ page.locator('#code-generator-model option[value="openai/gpt-5.6-sol"]');
+
+test.describe("STU-382 plans surface and paywall states", () => {
+ // The price assertions in this suite (A$11 / A$49) must render the AUD base
+ // value, never a converted one. formatPrice converts whenever the detected
+ // currency is not AUD, and that conversion would differ between a dev
+ // machine (Sydney TZ → A$11) and CI (UTC → $7.15) and fail non-hermetically.
+ // Pin the timezone so the verdict does not depend on the host.
+ test.use({ timezoneId: "Australia/Sydney", locale: "en-AU" });
+
+ test.beforeEach(async ({ page }) => {
+ await page.addInitScript(() => localStorage.setItem("hasSeenWalkthrough", "true"));
+ });
+
+ test("C1: professional labels and current-plan indicators agree across surfaces", async ({ page }) => {
+ await seedSession(page);
+ await applyDefaultRoutes(page, {
+ [ENDPOINTS.identity]: identityWithUsage({ count: 6 }),
+ [ENDPOINTS.getSubscription]: professionalSubscription(),
+ });
+ await page.goto("/#plans");
+
+ await expect(page.locator("#topbar-plan")).toHaveText("Pro");
+ await expect(page.locator("#subscription-tier-badge")).toHaveText("Pro");
+ await expect(page.locator("#plans-checkout-btn-professional")).toHaveText("Current plan");
+ await expect(page.locator("#plans-checkout-btn-professional")).toBeDisabled();
+ await expect(page.locator("#plans-checkout-btn-power")).toHaveText("Subscribe");
+ await expect(page.locator("#plans-free-current")).toBeHidden();
+
+ // Model gating agrees: Pro tier keeps Pro models unlocked and unlabelled.
+ await expect(proModelOption(page)).toBeEnabled();
+ await expect(proModelOption(page)).not.toContainText("(PRO)");
+ });
+
+ test("C3: a cancelled checkout is never reported as subscribed", async ({ page }) => {
+ await seedSession(page);
+ await applyDefaultRoutes(page, {
+ [ENDPOINTS.identity]: identityWithUsage(),
+ [ENDPOINTS.getSubscription]: freeSubscription(),
+ });
+ await page.goto("/?checkout=cancel");
+
+ await expect(page.locator("#topbar-plan")).toHaveText("Free");
+ await expect(page.locator("#subscription-tier-badge")).toHaveText("Free");
+ await expect(page.getByText("Checkout cancelled.")).toBeVisible();
+ // Never a live-plan claim.
+ await expect(page.getByText("Subscription active")).toHaveCount(0);
+ // Entitlement still free — Pro model stays labelled as gated.
+ await expect(proModelOption(page)).toContainText("(PRO)");
+ });
+
+ test("C3/C4: a checkout return alone never grants a paid tier before reconciliation", async ({ page }) => {
+ await seedSession(page);
+ // Contract still free (blocked / webhook not yet reconciled) even though
+ // the user returned with `?checkout=success`.
+ await applyDefaultRoutes(page, {
+ [ENDPOINTS.identity]: identityWithUsage(),
+ [ENDPOINTS.getSubscription]: freeSubscription(),
+ });
+ await page.goto("/?checkout=success");
+
+ // No entitlement change, anywhere.
+ await expect(page.locator("#topbar-plan")).toHaveText("Free");
+ await expect(page.locator("#subscription-tier-badge")).toHaveText("Free");
+ await expect(proModelOption(page)).toContainText("(PRO)");
+
+ // The confirmation is honest: it says "confirming", never "Subscription active".
+ await expect(page.getByText("confirming your subscription", { exact: false })).toBeVisible();
+ await expect(page.getByText("Subscription active")).toHaveCount(0);
+ });
+
+ test("C3: a verified return refreshes allowance and unlocks available models", async ({ page }) => {
+ await seedSession(page);
+ // Contract reconciled to professional — this is the ONLY thing that
+ // grants the paid tier, never the return param itself.
+ await applyDefaultRoutes(page, {
+ [ENDPOINTS.identity]: identityWithUsage({ count: 12 }),
+ [ENDPOINTS.getSubscription]: professionalSubscription(),
+ });
+ await page.goto("/?checkout=success");
+
+ await expect(page.locator("#topbar-plan")).toHaveText("Pro");
+ await expect(page.locator("#subscription-tier-badge")).toHaveText("Pro");
+ await expect(page.getByText("Subscription active!")).toBeVisible();
+
+ // Allowance refreshed from the reconciled contract: 50/run limit minus the
+ // 12 metered runs leaves 38 remaining in the topbar allowance.
+ await expect(page.locator("#topbar-credits-count")).toHaveText("38");
+
+ // Models refreshed from the reconciled contract.
+ await expect(proModelOption(page)).toBeEnabled();
+ await expect(proModelOption(page)).not.toContainText("(PRO)");
+ });
+
+ test("C4: no simulated purchase success survives; upgrade posts to the real Stripe endpoint", async ({ page }) => {
+ await seedSession(page);
+ await applyDefaultRoutes(page, {
+ [ENDPOINTS.identity]: identityWithUsage(),
+ [ENDPOINTS.getSubscription]: freeSubscription(),
+ });
+ await page.goto("/");
+
+ // The design's mock "preview" purchase-success dialogs must not ship,
+ // even hidden in the pricing modal. Scan the full document source so an
+ // inert/hidden mock cannot dodge the assertion.
+ const pageHtml = await page.content();
+ for (const mock of ["Subscription confirmed", "no charge was made", "Redirecting to checkout", "This is where the Stripe session would open"]) {
+ expect(pageHtml).not.toContain(mock);
+ }
+
+ // Upgrade goes to the real checkout endpoint — and never locally writes a
+ // paid subscription before Stripe returns.
+ let checkoutPosted = false;
+ await page.route(`${ENDPOINTS.createCheckout}`, async (route) => {
+ checkoutPosted = true;
+ await route.fulfill(checkoutSession());
+ });
+
+ await page.evaluate(() => window.openPricingModal());
+ await page.locator("#checkout-btn-professional").click();
+
+ await expect
+ .poll(() => checkoutPosted, { timeout: 5000 })
+ .toBe(true);
+ expect(checkoutPosted).toBe(true);
+ });
+
+ test("C4/C1: keyboard-reachable sign-in, upgrade and Manage actions render correctly", async ({ page }) => {
+ // Guest account view: Sign In and Upgrade are keyboard-focusable on mobile.
+ await applyDefaultRoutes(page, {
+ [ENDPOINTS.identity]: guestIdentity(),
+ [ENDPOINTS.getSubscription]: freeSubscription(),
+ });
+ await page.setViewportSize({ width: 360, height: 800 });
+ await page.goto("/#account");
+
+ const guestSignIn = page.locator('#auth-signedout button');
+ const guestUpgrade = page.locator('#auth-guest-usage button', { hasText: "Upgrade" });
+ await expect(guestSignIn).toBeVisible();
+ await expect(guestUpgrade).toBeVisible();
+ await guestSignIn.focus();
+ await expect(guestSignIn).toBeFocused();
+ await guestUpgrade.focus();
+ await expect(guestUpgrade).toBeFocused();
+
+ // Signed-in professional: Manage Subscription is reachable.
+ await page.setViewportSize({ width: 360, height: 800 });
+ await page.addInitScript(() =>
+ localStorage.setItem(
+ "ccc_auth_session",
+ JSON.stringify({ email: "plans@example.com", sessionToken: "session-token" }),
+ ),
+ );
+ await applyDefaultRoutes(page, {
+ [ENDPOINTS.identity]: identityWithUsage({ count: 12 }),
+ [ENDPOINTS.getSubscription]: professionalSubscription(),
+ });
+ await page.reload();
+ const manageBtn = page.locator("#manage-billing-btn");
+ await expect(manageBtn).toBeVisible();
+ await manageBtn.focus();
+ await expect(manageBtn).toBeFocused();
+ });
+
+ test("C5: the plans cards present only capabilities the product delivers, all as available", async ({ page }) => {
+ await seedSession(page);
+ await applyDefaultRoutes(page, {
+ [ENDPOINTS.identity]: identityWithUsage({ count: 6 }),
+ [ENDPOINTS.getSubscription]: professionalSubscription(),
+ });
+ await page.goto("/#plans");
+ const plansView = page.locator("#plans-view");
+
+ // The owner asked to KEEP the "API & MCP Access" and "All models + early
+ // access" rows, and confirmed BOTH capabilities ship. They must therefore
+ // read as available. API/MCP access is reached by contacting us rather than
+ // through a self-serve signup, so that row names its access path. The rows
+ // render from PLAN_FEATURES (the same source the modal uses). toContainText
+ // auto-waits until the JS render fills the lists, so we never race an
+ // un-rendered card.
+ await expect(plansView).toContainText("All models + early access");
+ await expect(plansView).toContainText("API & MCP access (contact us)");
+
+ const plansText = await plansView.innerText();
+ // No row may read as not-yet-available. An earlier revision marked these
+ // two "(coming soon)" on the incorrect inference that no user-facing
+ // surface existed in the codebase; the owner has since confirmed both ship.
+ const rows = plansText.split("\n");
+ for (const phrase of ["api & mcp", "early access"]) {
+ const matching = rows.filter((r) => r.toLowerCase().includes(phrase));
+ expect(matching.length).toBeGreaterThan(0);
+ for (const row of matching) {
+ expect(row).not.toMatch(/coming soon|not yet available|unavailable/i);
+ }
+ }
+
+ // Supported rows remain present and accurately priced. The AUD timezone pin
+ // above makes these price assertions hermetic (A$11/A$49, not a converted
+ // value) on any host.
+ await expect(page.locator("#plans-power-price")).toHaveText("A$49");
+ await expect(page.locator("#plans-pro-price")).toHaveText("A$11");
+ expect(plansText).toContain("BYOK");
+ expect(plansText).toContain("Code Regeneration");
+
+ // The pricing modal renders from the same PLAN_FEATURES source, so its
+ // Power card carries the same restored rows — the modal is covered, not
+ // just the plans page.
+ await page.evaluate(() => window.openPricingModal());
+ const modalPower = page.locator("#pricing-modal .pm-card-power .pm-features");
+ await expect(modalPower).toContainText("All models + early access");
+ await expect(modalPower).toContainText("API & MCP access (contact us)");
+ await expect(modalPower).toContainText("BYOK");
+ });
+});
+
+/**
+ * Criterion 2 — the Displayed price and the currency posted to the checkout
+ * must agree, for the AUD base and for a converted non-AUD fixture, using the
+ * app's own formatPrice/exchange-rate path.
+ */
+async function displayedProAndCheckoutCurrency(page, expectedDisplayed) {
+ // Auto-wait until updatePricingDisplay has formatted the price for the
+ // detected currency, so we never read the static HTML default.
+ await expect(page.locator("#plans-pro-price")).toHaveText(expectedDisplayed);
+ const displayed = await page.locator("#plans-pro-price").innerText();
+ let postedCurrency = null;
+ await page.route(`${ENDPOINTS.createCheckout}`, async (route) => {
+ const body = JSON.parse(route.request().postData() || "{}");
+ postedCurrency = body.currency;
+ await route.fulfill(checkoutSession());
+ });
+ // Start the real checkout flow directly; the plans page prices and the
+ // currency posted to /stripe/create-checkout-session-intl must agree.
+ await page.evaluate(() => window.startCheckout("professional"));
+ await expect.poll(() => postedCurrency, { timeout: 5000 }).not.toBeNull();
+ return { displayed, postedCurrency };
+}
+
+test.describe("STU-382 pricing currency matches checkout selection", () => {
+ test.use({ timezoneId: "Australia/Sydney", locale: "en-AU" });
+ test("AUD base formats as A$ and the checkout posts AUD", async ({ page }) => {
+ await seedSession(page);
+ await applyDefaultRoutes(page, {
+ [ENDPOINTS.identity]: identityWithUsage(),
+ [ENDPOINTS.getSubscription]: freeSubscription(),
+ });
+ await page.goto("/#plans");
+ const { displayed, postedCurrency } = await displayedProAndCheckoutCurrency(page, "A$11");
+ expect(displayed).toBe("A$11");
+ expect(postedCurrency).toBe("AUD");
+ });
+});
+
+test.describe("STU-382 pricing currency matches checkout selection (USD)", () => {
+ test.use({ timezoneId: "America/New_York", locale: "en-US" });
+ test("non-AUD fixture converts via exchange rates and the checkout posts USD", async ({ page }) => {
+ await seedSession(page);
+ await applyDefaultRoutes(page, {
+ [ENDPOINTS.identity]: identityWithUsage(),
+ [ENDPOINTS.getSubscription]: freeSubscription(),
+ });
+ await page.goto("/#plans");
+ const { displayed, postedCurrency } = await displayedProAndCheckoutCurrency(page, "$7.15");
+ // 11 AUD × 0.65 (fixture USD rate) = USD 7.15.
+ expect(displayed).toBe("$7.15");
+ expect(postedCurrency).toBe("USD");
+ });
+});
diff --git a/index.html b/index.html
index 7bc4bef..18e09ff 100644
--- a/index.html
+++ b/index.html
@@ -3199,44 +3199,30 @@
Account
Choose a plan
Unlock more generations and advanced features.
-
+
Free
$0/mo
-
-
2 generations/month
-
Gemini model only
-
Code Review
-
+
Current plan
-
+
Popular
Pro
For FlutterFlow Professionals
A$11/mo
billed monthly
-
-
50 generations/month
-
All models
-
Code Regeneration
-
+
-
+
Power
For Agencies & Enterprise
A$49/mo
billed monthly
-
-
2000 generations/month
-
All models + early access
-
BYOK support
-
API & MCP Access
-
Code Review
-
+
@@ -3614,46 +3600,32 @@
Choose a plan
-
+
Free
$0/mo
-
-
2 generations/month
-
Gemini model only
-
Code Review
-
+
Current plan
-
+
Popular
Pro
For FlutterFlow Professionals
A$11/mo
billed monthly
-
-
50 generations/month
-
All models
-
Code Regeneration
-
+
-
+
Power
For Agencies & Enterprise
A$49/mo
billed monthly
-
-
2000 generations/month
-
All models + early access
-
BYOK support
-
API & MCP Access
-
Code Review
-
+
diff --git a/screenshots/stu382-plans-guest-360x800.png b/screenshots/stu382-plans-guest-360x800.png
new file mode 100644
index 0000000..a173ab2
Binary files /dev/null and b/screenshots/stu382-plans-guest-360x800.png differ
diff --git a/screenshots/stu382-plans-professional-1440x900.png b/screenshots/stu382-plans-professional-1440x900.png
new file mode 100644
index 0000000..c774ae4
Binary files /dev/null and b/screenshots/stu382-plans-professional-1440x900.png differ
diff --git a/src/plansSurface.js b/src/plansSurface.js
new file mode 100644
index 0000000..f726985
--- /dev/null
+++ b/src/plansSurface.js
@@ -0,0 +1,66 @@
+// STU-382 — single source of truth for the plans surface.
+//
+// These are the canonical tier labels and generation limits shared by every
+// surface that surfaces a plan: the topbar, the account badge, the plans
+// page, the pricing modal and the paywall/generation gating. Keeping them in
+// one place is what makes acceptance criterion 1 ("Free/Pro/Power labels and
+// limits agree across surfaces") hold structurally rather than by accident of
+// repeated copy/paste.
+//
+// The feature list also encodes criterion 5: a row must EITHER map to a CURRENT
+// supported capability of the product, OR — when the owner has asked the row be
+// kept (the "API & MCP access" and "All models + early access" rows) — be
+// presented as CLEARLY not-yet-available rather than promised. Anything not
+// live yet is suffixed "(coming soon)" so it can never read as a capability the
+// app delivers today. This is the single source both the plans page and the
+// pricing modal render from, so the two surfaces cannot drift. BYOK is real (the
+// "Configure API Keys" surface) and remains listed.
+export const PLAN_LABELS = Object.freeze({
+ free: "Free",
+ professional: "Pro",
+ power: "Power",
+});
+
+export const PLAN_TIERS = Object.freeze(["free", "professional", "power"]);
+
+export function planLabel(tier) {
+ return PLAN_LABELS[tier] || PLAN_LABELS.free;
+}
+
+export const PLAN_LIMITS = Object.freeze({
+ free: 2,
+ professional: 50,
+ power: 2000,
+});
+
+// Rows that carry a capability which is real but is not self-serve: the
+// capability exists, so the row must present it as available, and the access
+// path is stated rather than implied. "API & MCP access" is shipped — customers
+// reach it by getting in contact, not through a self-serve signup — so it is
+// NOT marked unavailable. An earlier revision of this file marked it
+// "(coming soon)" on the incorrect inference that no user-facing surface
+// existed in the codebase; the owner has since confirmed it ships.
+export const CONTACT_TO_ACCESS_MARKER = "(contact us)";
+
+export const PLAN_FEATURES = Object.freeze({
+ free: Object.freeze([
+ "2 generations/month",
+ "Gemini model only",
+ "Code Review",
+ ]),
+ professional: Object.freeze([
+ "50 generations/month",
+ "All models",
+ "Code Regeneration",
+ "Code Review",
+ ]),
+ power: Object.freeze([
+ "2000 generations/month",
+ "All models",
+ "Code Regeneration",
+ "Bring your own key (BYOK)",
+ "Code Review",
+ "All models + early access",
+ `API & MCP access ${CONTACT_TO_ACCESS_MARKER}`,
+ ]),
+});
diff --git a/src/plansSurface.test.js b/src/plansSurface.test.js
new file mode 100644
index 0000000..55c30bf
--- /dev/null
+++ b/src/plansSurface.test.js
@@ -0,0 +1,132 @@
+// STU-382 — the plans surface must present one, internally-consistent set of
+// tier labels, limits and supported features, and it must never advertise a
+// capability the product does not actually deliver.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ PLAN_LABELS,
+ PLAN_TIERS,
+ PLAN_LIMITS,
+ PLAN_FEATURES,
+ planLabel,
+ CONTACT_TO_ACCESS_MARKER,
+} from "./plansSurface.js";
+
+// Every surface (topbar, account badge, plans page, usage dialog, gating)
+// resolves its label through planLabel() from this one map. Agreement is thus
+// structural, not accidental. This test pins the canonical set so a future
+// edit to one surface's label cannot silently diverge from the others.
+test("criterion 1: all three tiers resolve to one canonical label each", () => {
+ assert.equal(planLabel("free"), "Free");
+ assert.equal(planLabel("professional"), "Pro");
+ assert.equal(planLabel("power"), "Power");
+});
+
+test("criterion 1: every tier maps to a distinct, non-empty canonical label", () => {
+ const seen = new Set();
+ for (const tier of PLAN_TIERS) {
+ const label = planLabel(tier);
+ assert.ok(label.length > 0, `tier ${tier} must have a non-empty label`);
+ assert.ok(!seen.has(label), `label "${label}" must be unique per tier`);
+ seen.add(label);
+ }
+ assert.equal(seen.size, PLAN_TIERS.length);
+});
+
+test("criterion 1: limits ascend from free through power and match the gating contract", () => {
+ assert.equal(PLAN_LIMITS.free, 2);
+ assert.equal(PLAN_LIMITS.professional, 50);
+ assert.equal(PLAN_LIMITS.power, 2000);
+ assert.ok(PLAN_LIMITS.free < PLAN_LIMITS.professional);
+ assert.ok(PLAN_LIMITS.professional < PLAN_LIMITS.power);
+});
+
+// The whitelist of capabilities the live product actually delivers. A feature
+// row that is not on this list is a prototype promise (criterion 5) and must
+// not appear in a plan card.
+//
+// "API & MCP access" and "early access" are ON this list: the owner confirmed
+// both ship. API/MCP access is reached by contacting us rather than through a
+// self-serve signup, which is why that row names its access path — but the
+// capability is real, so the row must read as available, not as a promise.
+const SUPPORTED_CAPABILITIES = new Set([
+ "generations per month",
+ "code generation models",
+ "code review on every run",
+ "regenerate from flutterflow build errors",
+ "bring your own key (byok)",
+ "api & mcp access",
+ "early access",
+]);
+
+// A row counts as a supported capability when its text references one of the
+// whitelisted capabilities. Anything else must be clearly marked not-yet-
+// available to be allowed; an unmarked API/MCP surface or unattached "early
+// access" claim is a prototype promise and must be rejected.
+function capabilityOf(row) {
+ const normalized = row.toLowerCase();
+ // Checked before the generic "model" rule, because "All models + early
+ // access" contains both phrases and its distinguishing capability is the
+ // early-access one.
+ if (normalized.includes("api & mcp")) return "api & mcp access";
+ if (normalized.includes("early access")) return "early access";
+ if (normalized.includes("generation")) return "generations per month";
+ if (normalized.includes("model")) return "code generation models";
+ if (normalized.includes("review")) return "code review on every run";
+ if (normalized.includes("regenerat")) return "regenerate from flutterflow build errors";
+ if (normalized.includes("byok") || normalized.includes("api key")) return "bring your own key (byok)";
+ return null;
+}
+
+const NOT_AVAILABLE_WORDING = ["coming soon", "not yet available", "unavailable", "roadmap"];
+
+test("criterion 5: every feature row maps to a capability the product delivers", () => {
+ for (const tier of PLAN_TIERS) {
+ for (const row of PLAN_FEATURES[tier]) {
+ const capability = capabilityOf(row);
+ assert.notEqual(
+ capability,
+ null,
+ `feature "${row}" (${tier}) does not map to any supported capability — it would read as a promise`,
+ );
+ assert.ok(
+ SUPPORTED_CAPABILITIES.has(capability),
+ `feature "${row}" maps to unsupported capability "${capability}"`,
+ );
+ }
+ }
+});
+
+test("product decision: the owner-requested rows are restored and read as available", () => {
+ // The owner asked to keep these two rows (STU-382 review decision) and
+ // confirmed both capabilities ship. They must render on the Power card and
+ // must NOT be marked unavailable — an earlier revision wrongly suffixed
+ // them "(coming soon)" on the incorrect inference that no user-facing
+ // surface existed in the codebase.
+ const power = PLAN_FEATURES.power;
+ const joined = power.join(" … ").toLowerCase();
+
+ assert.ok(joined.includes("all models + early access"), 'Power card must keep "All models + early access"');
+ assert.ok(
+ joined.includes(`api & mcp access ${CONTACT_TO_ACCESS_MARKER.toLowerCase()}`),
+ `Power card must present "API & MCP access ${CONTACT_TO_ACCESS_MARKER}" — the capability ships, so it names its access path rather than reading as a promise`,
+ );
+
+ for (const row of power) {
+ for (const wording of NOT_AVAILABLE_WORDING) {
+ assert.ok(
+ !row.toLowerCase().includes(wording),
+ `Power row "${row}" reads as not-yet-available, but the owner confirmed this capability ships`,
+ );
+ }
+ }
+});
+
+test("criterion 5: BYOK — a real capability — is the power-only differentiator", () => {
+ const power = PLAN_FEATURES.power.join(" ").toLowerCase();
+ const pro = PLAN_FEATURES.professional.join(" ").toLowerCase();
+ const free = PLAN_FEATURES.free.join(" ").toLowerCase();
+ assert.ok(power.includes("byok"), "Power must advertise BYOK (real capability)");
+ assert.ok(!pro.includes("byok"), "BYOK is Power-only, not Pro");
+ assert.ok(!free.includes("byok"), "BYOK is Power-only, not Free");
+});