From c43f1e021bb8d4387417c06fe36245d2de7b938e Mon Sep 17 00:00:00 2001 From: Mehar Khanna Date: Thu, 12 Nov 2026 23:28:23 -0500 Subject: [PATCH 1/5] genclass 0.3.7: a new or connected app opens its plan page at once (login, connect, init, apps new, and its views); the header shows the app's plan; the dashboard note says Free shows the totals Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 2 +- packages/cli/CHANGELOG.md | 12 +++++ packages/cli/README.md | 2 +- packages/cli/package.json | 2 +- packages/cli/src/actions.mjs | 45 +++++++++++++++++++ packages/cli/src/app.mjs | 27 +++++++---- packages/cli/src/ask.mjs | 3 +- packages/cli/src/cli.mjs | 12 ++++- packages/cli/src/manage.mjs | 30 +++---------- packages/cli/src/setup.mjs | 4 +- packages/cli/src/state.mjs | 3 ++ packages/cli/test/server-account.test.ts | 4 +- packages/cli/test/state.test.ts | 28 +++++++++++- packages/runtime/CHANGELOG.md | 4 ++ packages/runtime/bin/lib/init.mjs | 8 ++-- packages/runtime/bin/lib/login.mjs | 2 +- packages/runtime/test/install/account.test.ts | 8 ++-- packages/runtime/test/install/cli.test.ts | 7 +-- 18 files changed, 149 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index 33ecfb7e..54a00da4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16819,7 +16819,7 @@ }, "packages/cli": { "name": "genclass", - "version": "0.3.6", + "version": "0.3.7", "license": "Apache-2.0", "bin": { "genclass": "bin/genclass.mjs" diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 9efaf9df..0ec6a335 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.3.7 + +- **Every new app has its plan chosen right away.** `login`, `connect` and `init` (from the home prompt too) and + `apps new` open the app's plan page in the browser once it is in the account, and wait for the choice: Pro, with 3 + days free, or Free with a reason. Before, only the guided setup did, so an app connected another way stayed on Free + without anyone choosing. +- Opening an app with no plan chosen (`dashboard`, `problems`, ...) asks for its plan first, as its page on + genclass.dev does. +- The header shows this folder's app's plan, not the best plan of the account. `profile` and `plan` say "no plan + chosen" for an app that has none. +- The note under a dashboard link says what Free shows (the totals) and what comes with Pro. + ## 0.3.6 The paid plan is Pro (it was called Team), with 3 days free: diff --git a/packages/cli/README.md b/packages/cli/README.md index 1970d31c..8acbd290 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11,7 +11,7 @@ Run it in your web app's folder. It shows where the project stands and walks you per step. Stop at any point and run it again: it continues from what is true now. ```text - ╭─ GenClass ────────────────────────────────────────────── v0.3.6 ─╮ + ╭─ GenClass ────────────────────────────────────────────── v0.3.7 ─╮ │ │ │ Welcome back, Ada! │ │ │ diff --git a/packages/cli/package.json b/packages/cli/package.json index aabc05f3..55f94313 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "genclass", - "version": "0.3.6", + "version": "0.3.7", "description": "GenClass CLI: one command that sets GenClass up, shows your app in the terminal (problems, analytics, events), hands problems to your coding agent, answers questions about your app and tells you what it is missing.", "type": "module", "license": "Apache-2.0", diff --git a/packages/cli/src/actions.mjs b/packages/cli/src/actions.mjs index 0700c206..d092f7ea 100644 --- a/packages/cli/src/actions.mjs +++ b/packages/cli/src/actions.mjs @@ -135,6 +135,51 @@ export async function choosePlan(state, o = {}, deps = {}) { return "skipped"; } +/** + * What a paid plan for one app is, from the server's offer (genclass.dev's own words and price): this folder's app, + * or the one `name` names, else the account's first app past the ones Free covers, else its first app on Free. Null + * when that app has a plan (or a grant), when nothing is sold here, and when every app has one. A server from before + * plans per project: the account's offer as it sent it. + */ +export function upgradeOffer(state, name = null) { + const base = state.account?.offer ?? state.steps?.find((x) => x.id === "plan")?.offer ?? null; + if (!base) return null; + const mine = (state.projects ?? []).filter((p) => p.plan && !p.shared); + if (!mine.length) return base.project ? base : (state.projects ?? []).some((p) => p.plan) ? null : base; + const named = name ? mine.find((p) => p.id === name || p.name.toLowerCase() === String(name).toLowerCase()) : null; + const here = mine.find((p) => p.token === state.app?.token) ?? null; + const target = named ?? here ?? mine.find((p) => p.needsPlan) ?? mine.find((p) => p.plan.id === "free" && !p.plan.source) ?? null; + if (!target || target.plan.id !== "free" || target.plan.source || !target.planUrl) return null; + return { ...base, checkoutUrl: `${target.planUrl}&from=cli`, project: { id: target.id, name: target.name, token: target.token }, needs: target.needsPlan === true }; +} + +/** + * An app of the account with no plan chosen yet (on Free, nothing paid, granted or chosen on its page): the owner's + * rule (2026-11-12) is that it has its plan chosen before anything else, Pro or Free with a reason, so this opens its + * plan page in the browser and waits for the choice. `id` names the app; without it, this folder's. Without a + * terminal it says where the page is and goes on. Returns "chosen", "none" (nothing to choose: a plan, a grant, a + * choice already, or nothing sold) or "open" (the choice was not made). + */ +export async function planNow(o = {}, deps = {}, id = null) { + let state; + try { + state = await (deps.readState ?? (await import("./state.mjs")).readState)({ cwd: o.cwd }, deps); + } catch { + return "none"; + } + const app = (state.projects ?? []).find((p) => (id ? p.id === id : p.token === state.app?.token) && !p.shared); + if (!app?.plan || app.plan.id !== "free" || app.plan.source || (app.planChosen && !app.needsPlan)) return "none"; + const offer = upgradeOffer(state, app.id); + if (!offer?.project) return "none"; + if (o.json) return "open"; + if (o.yes || !interactive()) { + out(); + note(`${clean(app.name, 40)} has no plan yet. Choose it in the browser: ${offer.checkoutUrl}`); + return "open"; + } + return (await projectPlan(offer, o, deps)) === "done" ? "chosen" : "open"; +} + /** * Opens a plan's checkout in the browser and waits for the payment (Enter stops the wait). The page is * genclass.dev's, the payment is Stripe's; this command never sees a card. Returns whether the plan became active. diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs index f5cdb03a..0cf99354 100644 --- a/packages/cli/src/app.mjs +++ b/packages/cli/src/app.mjs @@ -13,9 +13,9 @@ import { readCredentialsFor } from "../../runtime/bin/lib/account.mjs"; import { hostConfig, hostUrl } from "../../runtime/bin/lib/host.mjs"; import { c, interactive, out, sym } from "../../runtime/bin/lib/ui.mjs"; -import { openBrowser, signIn } from "./actions.mjs"; +import { openBrowser, planNow, signIn } from "./actions.mjs"; import { fetchApp, signInIsOld } from "./api.mjs"; -import { readState } from "./state.mjs"; +import { planName, readState } from "./state.mjs"; import { ago, bad, clean, header, note, proBox, PROMPT, typed, visible, warn, width, wrap } from "./tui.mjs"; /** The views a Free app does not have (the server sends their data to Pro only), and what the Pro box says about each. */ @@ -238,7 +238,7 @@ export async function pickApp(o = {}, deps = {}, name = "dashboard") { // on a terminal: the apps as numbered choices, and the number picks one if (wanted.length !== 1 && projects.length && !o.json && !o.yes && interactive()) { const list = (wanted.length > 1 ? wanted : projects).slice(0, 30); - header({ app: state.app.name, framework: state.app.framework, account: state.account?.label, plan: state.account?.plan?.name, host: state.host }); + header({ app: state.app.name, framework: state.app.framework, account: state.account?.label, plan: planName(state), host: state.host }); note(wanted.length > 1 ? `More than one app is named "${clean(o.app, 40)}". Which one?` : o.app ? `No app named "${clean(o.app, 40)}". Which one?` : state.app.token ? "This folder's app is not in your account yet. Which app?" : "Which app?"); list.forEach((p, i) => out(` ${c.gray(String(i + 1).padStart(2))} ${c.bold(clean(appLabel(p, projects), 34))}${p.last_event ? c.gray(` last page ${ago(p.last_event, deps.now)}`) : ""}`)); out(); @@ -251,7 +251,7 @@ export async function pickApp(o = {}, deps = {}, name = "dashboard") { if (wanted.length !== 1) { if (o.json) out(JSON.stringify({ ok: false, error: projects.length ? "which app" : "no app yet", apps: projects.map((p) => p.name) })); else { - header({ app: state.app.name, framework: state.app.framework, account: state.account?.label, plan: state.account?.plan?.name, host: state.host }); + header({ app: state.app.name, framework: state.app.framework, account: state.account?.label, plan: planName(state), host: state.host }); if (!projects.length) note("No app in your account yet. Run npx genclass in your web app's folder: it adds GenClass and gives the app its dashboard."); else { note(wanted.length > 1 ? `More than one app is named "${clean(o.app, 40)}". Name it by its id:` : o.app ? `No app named "${clean(o.app, 40)}" in your account. Your apps:` : state.app.token ? "This folder's app is not in your account yet (genclass puts it there). Your apps:" : "Which app? Run this in the app's folder, or name one:"); @@ -277,11 +277,22 @@ export async function appView(o = {}, deps = {}, view) { if ("code" in picked) return picked.code; let { state } = picked; const { creds, target } = picked; + // an app of the account with no plan chosen opens its plan page first, as its page on genclass.dev does + if (!o.json && interactive()) { + const p = await planNow({ cwd: o.cwd, yes: o.yes }, deps, target.id); + if (p === "open") { + out(); + note(`${clean(target.name, 40)} needs its plan first. Run this again once it is chosen.`); + out(); + return 1; + } + if (p === "chosen") state = await readState({ cwd: o.cwd }, deps); + } if (o.open && target.dashboard_url) openBrowser(target.dashboard_url, o); let app = await fetchApp(creds, target.id, range, deps); if (!app.ok && app.reauth && !o.json && interactive()) { // signed in before the command could show dashboards: one more approval in the browser, then it is shown - header({ app: target.name, framework: state.app.framework, account: state.account?.label, plan: state.account?.plan?.name, host: state.host }); + header({ app: target.name, framework: state.app.framework, account: state.account?.label, plan: planName(state, target.token), host: state.host }); note("This sign-in is from before the command could show dashboards, so it was not approved for that. Signing in once more fixes it."); const a = await typed(` ${c.bold("Enter")} ${c.gray("to sign in again,")} ${c.bold("q")} ${c.gray("to stop")} `); if (a === null || /^q/i.test(a)) return 1; @@ -293,7 +304,7 @@ export async function appView(o = {}, deps = {}, view) { if (!app.ok) { if (o.json) out(JSON.stringify({ ok: false, error: app.reauth ? "sign in again" : app.reason, run: app.reauth ? "npx genclass login" : null, dashboard_url: target.dashboard_url })); else { - header({ app: target.name, framework: state.app.framework, account: state.account?.label, plan: state.account?.plan?.name, host: state.host }); + header({ app: target.name, framework: state.app.framework, account: state.account?.label, plan: planName(state, target.token), host: state.host }); if (app.reauth) warn(`This sign-in is from before the command could show dashboards. Run ${c.cyan("npx genclass login")} once, then ${c.cyan(`npx genclass ${view.name}`)}.`); else if (app.missing) note("This genclass.dev cannot show a dashboard in the terminal yet."); else bad(`Could not read the dashboard: ${clean(app.reason, 200)}`); @@ -313,13 +324,13 @@ export async function appView(o = {}, deps = {}, view) { } // Free sees the totals only (the server left the rest out): these views are Pro's, and say so with the way to it if (app.problemsPlan && PRO_VIEWS[view.name]) { - header({ app: target.name, framework: state.app.token === target.token ? state.app.framework : null, account: state.account?.label, plan: state.account?.plan?.name, host: state.host }); + header({ app: target.name, framework: state.app.token === target.token ? state.app.framework : null, account: state.account?.label, plan: planName(state, target.token), host: state.host }); proBox(PRO_VIEWS[view.name], app.problemsPlan); out(); return 0; } // a view whose output is meant to be piped or pasted (the report) has no card over it - if (!view.plain) header({ app: target.name, framework: state.app.token === target.token ? state.app.framework : null, account: state.account?.label, plan: state.account?.plan?.name, host: state.host }); + if (!view.plain) header({ app: target.name, framework: state.app.token === target.token ? state.app.framework : null, account: state.account?.label, plan: planName(state, target.token), host: state.host }); const code = await view.show(app, state, target, readCredentialsFor().creds ?? creds); return typeof code === "number" ? code : 0; } diff --git a/packages/cli/src/ask.mjs b/packages/cli/src/ask.mjs index cb9d12c6..aba2615e 100644 --- a/packages/cli/src/ask.mjs +++ b/packages/cli/src/ask.mjs @@ -12,6 +12,7 @@ // - No dead end: no model on the server, the day's runs used up, or a model that did not answer each say so and // name the command that shows the numbers themselves. +import { planName } from "./state.mjs"; import { c, interactive, out, sym } from "../../runtime/bin/lib/ui.mjs"; import { askModel } from "./api.mjs"; import { n, pickApp, RANGE_WORDS } from "./app.mjs"; @@ -80,7 +81,7 @@ export async function ask(o = {}, deps = {}) { if ("code" in picked) return picked.code; const { state, creds, target } = picked; const range = ["24h", "7d", "30d", "90d", "1y"].includes(o.range) ? o.range : "7d"; - if (!o.json) header({ app: target.name, framework: state.app.token === target.token ? state.app.framework : null, account: state.account?.label, plan: state.account?.plan?.name, host: state.host }); + if (!o.json) header({ app: target.name, framework: state.app.token === target.token ? state.app.framework : null, account: state.account?.label, plan: planName(state, target.token), host: state.host }); // once per machine: what is sent, and where const prefs = (deps.readPrefs ?? readPrefs)(); if (!prefs.askTold) { diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 63a18ffe..ed310baa 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -94,9 +94,17 @@ export async function run(argv, ctx = {}) { const again = await (await import("./app.mjs")).signInAgain(parseArgs(args).o); if (again !== null) return again; } - if (["login", "logout", "whoami", "connect"].includes(cmd)) return (await import("../../runtime/bin/lib/login.mjs")).run(cmd, args); + // an app that login, connect or init put in the account opens its plan page next (actions.mjs planNow) + const thenPlan = async (code) => { + if (code !== 0 || ctx.inWalk) return code; + const i = args.indexOf("--cwd"); + await (await import("./actions.mjs")).planNow({ cwd: i >= 0 ? args[i + 1] : undefined, yes: args.includes("--yes"), json: args.includes("--json") }).catch(() => "none"); + return code; + }; + if (["logout", "whoami"].includes(cmd)) return (await import("../../runtime/bin/lib/login.mjs")).run(cmd, args); + if (["login", "connect"].includes(cmd)) return thenPlan(await (await import("../../runtime/bin/lib/login.mjs")).run(cmd, args)); if (cmd === "remove") return (await import("./remove.mjs")).remove(args); - if (cmd === "init") return (await import("../../runtime/bin/lib/init.mjs")).run(cmd, args); + if (cmd === "init") return thenPlan(await (await import("../../runtime/bin/lib/init.mjs")).run(cmd, args)); if (cmd === "scan" || cmd === "audit") { // this command carries no copy of the runtime's script-tag build, so the page check runs on genclass.dev unless asked otherwise const withHost = args.includes("--local") || args.includes("--hosted") || args.includes("--help") ? args : [...args, "--hosted"]; diff --git a/packages/cli/src/manage.mjs b/packages/cli/src/manage.mjs index c1fac798..6873c8ef 100644 --- a/packages/cli/src/manage.mjs +++ b/packages/cli/src/manage.mjs @@ -16,12 +16,12 @@ import { resolve } from "node:path"; import { createAccountProject, readCredentialsFor } from "../../runtime/bin/lib/account.mjs"; import { hostUrl } from "../../runtime/bin/lib/host.mjs"; import { c, interactive, out, sym } from "../../runtime/bin/lib/ui.mjs"; -import { checkout, mcpServer, openBrowser, projectPlan } from "./actions.mjs"; +import { checkout, mcpServer, openBrowser, planNow, projectPlan, upgradeOffer } from "./actions.mjs"; import { changeConnection, createInvite, fetchAccount, fetchApp, fetchOverview, fetchConnections, fetchEvents, makeAgentLink, saveFunnels } from "./api.mjs"; import { appArg, n, pickApp } from "./app.mjs"; import { copyText, onPath } from "./fix.mjs"; import { asking, refused } from "./session.mjs"; -import { readState } from "./state.mjs"; +import { planName, readState } from "./state.mjs"; import { ago, agree, bad, clean, good, header, note, planCard, PROMPT, secret, spinner, typed, warn, width, wrap } from "./tui.mjs"; import { agentSetup } from "./views.mjs"; @@ -31,7 +31,7 @@ const row = (label, text, w = 16) => { out(` ${c.gray(label.padEnd(w))}${first}`); for (const l of rest) out(` ${" ".repeat(w)}${l}`); }; -const top = (state, target = null) => header({ app: target?.name ?? state.app.name, framework: !target || state.app.token === target.token ? state.app.framework : null, account: state.account?.label, plan: state.account?.plan?.name, host: state.host }); +const top = (state, target = null) => header({ app: target?.name ?? state.app.name, framework: !target || state.app.token === target.token ? state.app.framework : null, account: state.account?.label, plan: planName(state), host: state.host }); const day = (iso) => String(iso ?? "").slice(0, 10); /** Signed in, or the lines that say how to. Returns { state, creds } or { code }. */ async function signedIn(o, deps, name) { @@ -86,6 +86,8 @@ export async function apps(o = {}, deps = {}) { warn("Free covers 3 apps: this one needs a paid plan."); out(` ${c.gray("Its plan:")} ${c.cyan(made.planUrl)}`); } else if (made?.plan?.id === "free" && made.planUrl) out(` ${c.gray("On Free (limited). Its plan:")} ${c.cyan(made.planUrl)}`); + // its plan is chosen now, on its page (Pro, or Free with a reason) + if (made?.id) await planNow({ cwd: o.cwd }, deps, made.id); note("To put GenClass into that app's code, run npx genclass in its folder and pick this app."); } out(); @@ -165,7 +167,7 @@ export async function plan(o = {}, deps = {}) { out(); out(` ${c.bold("Apps and their plans")}`); for (const p of mine) { - const what = p.needsPlan ? c.yellow("needs a plan") : p.plan.id === "free" ? c.gray("Free") : c.bold(clean(p.plan.name, 20)); + const what = p.needsPlan ? c.yellow("needs a plan") : p.plan.id === "free" ? (p.planChosen ? c.gray("Free") : c.yellow("no plan chosen")) : c.bold(clean(p.plan.name, 20)); out(` ${clean(p.name, 24).padEnd(24)} ${what}${p.token === state.app?.token ? c.gray(" this folder") : ""}`); } } @@ -199,24 +201,6 @@ export async function plan(o = {}, deps = {}) { // ------------------------------------------------------------------------------------- profile, upgrade, Free -/** - * What a paid plan for one app is, from the server's offer (genclass.dev's own words and price): this folder's app, - * or the one `name` names, else the account's first app past the ones Free covers, else its first app on Free. Null - * when that app has a plan (or a grant), when nothing is sold here, and when every app has one. A server from before - * plans per project: the account's offer as it sent it. - */ -export function upgradeOffer(state, name = null) { - const base = state.account?.offer ?? state.steps?.find((x) => x.id === "plan")?.offer ?? null; - if (!base) return null; - const mine = (state.projects ?? []).filter((p) => p.plan && !p.shared); - if (!mine.length) return base.project ? base : (state.projects ?? []).some((p) => p.plan) ? null : base; - const named = name ? mine.find((p) => p.id === name || p.name.toLowerCase() === String(name).toLowerCase()) : null; - const here = mine.find((p) => p.token === state.app?.token) ?? null; - const target = named ?? here ?? mine.find((p) => p.needsPlan) ?? mine.find((p) => p.plan.id === "free" && !p.plan.source) ?? null; - if (!target || target.plan.id !== "free" || target.plan.source || !target.planUrl) return null; - return { ...base, checkoutUrl: `${target.planUrl}&from=cli`, project: { id: target.id, name: target.name, token: target.token }, needs: target.needsPlan === true }; -} - /** Free's limits in a few words, from the numbers the server sent ("3 apps, 100 live fixes a month"). */ const freeWords = (free) => [free?.apps ? `${free.apps} apps` : null, free?.liveFixes ? `${free.liveFixes} live fixes a month` : null, free?.historyDays ? `${free.historyDays} days of history` : null].filter(Boolean).join(", "); @@ -301,7 +285,7 @@ export async function profile(o = {}, deps = {}) { out(); out(` ${c.bold("Apps and their plans")}`); for (const p of mine) { - const what = p.needsPlan ? c.yellow("needs a plan") : p.plan.id === "free" ? c.gray("Free") : c.bold(clean(p.plan.name, 20)); + const what = p.needsPlan ? c.yellow("needs a plan") : p.plan.id === "free" ? (p.planChosen ? c.gray("Free") : c.yellow("no plan chosen")) : c.bold(clean(p.plan.name, 20)); out(` ${clean(p.name, 24).padEnd(24)} ${what}${p.token === state.app?.token ? c.gray(" this folder") : ""}`); } } diff --git a/packages/cli/src/setup.mjs b/packages/cli/src/setup.mjs index a8a796c5..13b2e147 100644 --- a/packages/cli/src/setup.mjs +++ b/packages/cli/src/setup.mjs @@ -24,7 +24,7 @@ import { c, interactive, out } from "../../runtime/bin/lib/ui.mjs"; import { addRuntime, addWorkflow, choosePlan, connectAgent, connectGithubApp, signIn, watchFirstPage } from "./actions.mjs"; import { brand, canDraw } from "./art.mjs"; import { BUILD } from "./assets.mjs"; -import { appsBelow, readState, stateJson } from "./state.mjs"; +import { appsBelow, planName, readState, stateJson } from "./state.mjs"; import { barPara, card, checklist, header, nextBlock, note, pick, PROMPT, rule, summary, typed, warn } from "./tui.mjs"; /** @@ -32,7 +32,7 @@ import { barPara, card, checklist, header, nextBlock, note, pick, PROMPT, rule, * about it. */ export function show(state, details = false, welcome = false) { - const h = { app: state.app.name, framework: state.app.framework, account: state.account?.label ?? null, plan: state.account?.plan?.name ?? null, host: state.host }; + const h = { app: state.app.name, framework: state.app.framework, account: state.account?.label ?? null, plan: planName(state), host: state.host }; // the first screen on a terminal is the welcome card; every later one, and anything piped, is the two-line header if (welcome && canDraw()) card({ ...h, cwd: state.cwd, version: BUILD.version }); else header(h); diff --git a/packages/cli/src/state.mjs b/packages/cli/src/state.mjs index 02ecd09d..f25ba8a7 100644 --- a/packages/cli/src/state.mjs +++ b/packages/cli/src/state.mjs @@ -395,6 +395,9 @@ export async function readState(o = {}, deps = {}) { }; } +/** The plan of the app with `token` (plans are per project), else the account's: what a header shows. */ +export const planName = (state, token = state?.app?.token) => (state?.projects ?? []).find((p) => p.token === token && p.plan)?.plan?.name ?? state?.account?.plan?.name ?? null; + /** The state as plain data for `--json`: what a coding agent reads to know where things stand and what to run. */ /** The command for a step as a coding agent runs it: without a terminal the walk does a step only with --yes. */ const forAgent = (run) => (run === "npx genclass setup" ? "npx genclass setup --yes" : run); diff --git a/packages/cli/test/server-account.test.ts b/packages/cli/test/server-account.test.ts index 639127ce..3385629f 100644 --- a/packages/cli/test/server-account.test.ts +++ b/packages/cli/test/server-account.test.ts @@ -105,7 +105,7 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { expect(f).toContain("$15 / project / month"); // plans are per project: the account's one app on Free has its own plan page expect(text).toContain(`${HOST}/account/billing?project=${me.app.id}&from=cli`); - expect(f).toContain("Apps and their plans Shop Free"); + expect(f).toContain("Apps and their plans Shop no plan chosen"); expect(JSON.parse((await said(() => plan({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: true, plan: { id: "free" }, limits: { apps: 3 }, offer: { plan: { id: "team" } } }); }); @@ -124,7 +124,7 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { expect(f).toContain("Profile"); expect(f).toContain("Signed in as ada@example.com"); expect(f).toContain("Plan Free (limited)"); - expect(f).toContain("Apps and their plans Shop Free"); + expect(f).toContain("Apps and their plans Shop no plan chosen"); expect(f).toContain("Upgrade Shop to Pro $15 / project / month npx genclass upgrade"); expect(f).toContain("Sign out npx genclass logout"); expect(JSON.parse((await said(() => profile({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: true, plan: { id: "free" }, apps: [{ name: "Shop", plan: { id: "free" } }], upgrade_url: `${HOST}/account/billing?project=${me.app.id}&from=cli` }); diff --git a/packages/cli/test/state.test.ts b/packages/cli/test/state.test.ts index f603f365..5d8d06b9 100644 --- a/packages/cli/test/state.test.ts +++ b/packages/cli/test/state.test.ts @@ -5,9 +5,9 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { saveCredentials } from "../../runtime/bin/lib/account.mjs"; import { run as runInit } from "../../runtime/bin/lib/init.mjs"; -import { choosePlan } from "../src/actions.mjs"; +import { choosePlan, planNow } from "../src/actions.mjs"; import { status } from "../src/setup.mjs"; -import { agentConfigs, appsBelow, genclassWorkflows, hasPlaywright, readState, stateJson, WORKFLOW_PATH } from "../src/state.mjs"; +import { agentConfigs, appsBelow, genclassWorkflows, hasPlaywright, planName, readState, stateJson, WORKFLOW_PATH } from "../src/state.mjs"; import { renamedRepo } from "../src/git.mjs"; import { saveNotes } from "../src/store.mjs"; import { planCard } from "../src/tui.mjs"; @@ -414,6 +414,30 @@ describe("genclass: the plan step (src/state.mjs, src/actions.mjs choosePlan, sr expect((await read(project({ shared: true }))).steps.find((x) => x.id === "plan")).toMatchObject({ state: "optional", detail: "the team's owner chooses it" }); }); + it("an app with no plan chosen gets its plan page at once (after login, connect, init, apps new, and before its views); one with a plan or a choice does not", async () => { + const dir = tempDir(); + viteApp(dir); + await addRuntime(dir); + signIn(); + const url = `${HOST}/account/billing?project=p1`; + const app = (extra: object) => project({ id: "p1", plan: { id: "free", name: "Free", source: null }, needs_plan: false, plan_chosen: false, plan_url: url, ...extra }); + const read = (p: object) => readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ offer: { ...OFFER, price: "$15 / project / month" }, projects: [p] }) } }).fetchFn }); + // nobody at a terminal here: it says where the page is and goes on + let r = ""; + const said = await printed(async () => (r = await planNow({ cwd: dir }, { readState: async () => read(app({})) }))); + expect(r).toBe("open"); + expect(said.replace(/\s+/g, " ")).toContain(`shop has no plan yet. Choose it in the browser: ${url}&from=cli`); + // the app named by its id, the same + expect(await planNow({ cwd: dir }, { readState: async () => read(app({})) }, "p1")).toBe("open"); + // nothing to choose: Free chosen with a reason, Pro paid, or GenClass's grant + for (const p of [app({ plan_chosen: true }), app({ plan: { id: "team", name: "Pro", source: "paid" } }), app({ plan: { id: "team", name: "Pro", source: "comp" } })]) { + expect(await printed(async () => expect(await planNow({ cwd: dir }, { readState: async () => read(p) })).toBe("none"))).toBe(""); + } + // the header names this app's plan, not the account's best one + const st = await read(app({ plan: { id: "free", name: "Free", source: null } })); + expect(planName({ ...st, account: { ...st.account, plan: { id: "team", name: "Pro" } } } as never)).toBe("Free"); + }); + it("the card is the paid plan, large, in the server's words, as wide as the welcome card", async () => { const cols = process.stdout.columns; try { diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index f5135e55..5302a266 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- The CLI's note under a dashboard link says what each plan shows since genclass.dev's plans changed (2026-11-12): + Free the totals for the last 7 days; every problem, its pages, fix prompts and visitor analytics with Pro (3 days + free). + - **`init`/`connect` recognise more existing setups:** `@genclass/nuxt` and `@genclass/astro` in a config, and the key of a hand-written setup (`bin/lib/init.mjs` `inspectProject`). `connect --yes` refuses to replace a key that is none of the account's apps unless `--app` or `--name` says which (`bin/lib/login.mjs`). Run by a host CLI diff --git a/packages/runtime/bin/lib/init.mjs b/packages/runtime/bin/lib/init.mjs index 30060afd..5ac32ef9 100644 --- a/packages/runtime/bin/lib/init.mjs +++ b/packages/runtime/bin/lib/init.mjs @@ -30,10 +30,10 @@ export const INTERCEPTION_URL = "https://genclass.dev/docs/interception"; export const PRIVACY_URL = `${REPO}/PRIVACY.md`; /** The plans page: forwarding to Sentry, PostHog and Datadog is not part of the free runtime; the genclass.dev collector does it on the Team and Enterprise plans. */ export const PRICING_URL = "https://genclass.dev/pricing"; -/** What a dashboard shows, said under every dashboard link the CLI prints: the problems are free, visitor analytics come with Team. */ +/** What a dashboard shows, said under every dashboard link the CLI prints: Free the totals, Pro the rest (since 2026-11-12). */ export const FREE_LINES = [ - "Free for every app: what GenClass found, on which pages, and what it did, for the last 7 days. How visitors", - `use each page (analytics: true) shows on the Team plan. Print this link again any time: ${CMD} dashboard`, + "Free shows this app's totals for the last 7 days. Every problem, the pages it happens on, a fix prompt for", + `each and visitor analytics come with Pro (3 days free): ${PRICING_URL}. This link again: ${CMD} dashboard`, ]; export const USAGE = `Usage: @@ -57,7 +57,7 @@ remove takes out exactly what init added (the lines and blocks marked "${MARK} approve the changes (or pass --yes), init creates one at genclass.dev (sending package.json's name), writes it into what it adds, and saves the token and your private dashboard link in ${LOCAL_FILE} (the dashboard is free; visitor analytics - come with the Team plan; \`dashboard\` prints the link again). Signed in (\`login\`), init first asks + come with the Pro plan; \`dashboard\` prints the link again). Signed in (\`login\`), init first asks whether the new app goes into your GenClass account instead (--yes or --in-account: it does) --no-token no token, no network (no dashboard; you can add one later: ${START_URL}) --no-telemetry turn off GenClass's anonymous diagnostics (on by default; see TELEMETRY.md and diff --git a/packages/runtime/bin/lib/login.mjs b/packages/runtime/bin/lib/login.mjs index 17fb905e..c146563c 100644 --- a/packages/runtime/bin/lib/login.mjs +++ b/packages/runtime/bin/lib/login.mjs @@ -55,7 +55,7 @@ whoami shows the signed-in account and its apps. logout ends the CLI session at genclass.dev and deletes the saved file. dashboard prints the link to this app's dashboard, free for every app: what GenClass found, on which pages - and what it did (how visitors use each page, with analytics: true, shows on the Team plan). It reads the private link in + and what it did (Free shows the totals; every problem, its pages and how visitors use each page come with the Pro plan). It reads the private link in ${LOCAL_FILE} without contacting anything; signed in, it finds the app in your account (or lists your apps). diff --git a/packages/runtime/test/install/account.test.ts b/packages/runtime/test/install/account.test.ts index 14ba43ca..602501d2 100644 --- a/packages/runtime/test/install/account.test.ts +++ b/packages/runtime/test/install/account.test.ts @@ -475,8 +475,9 @@ describe("whoami and logout", () => { }); }); -describe("dashboard: this app's free analytics, as a link in the terminal", () => { - const PAYWALL = /needs\s+Pro|free trial|trial for analytics|come with Pro/i; +describe("dashboard: this app's link in the terminal, and what Free and Pro show (2026-11-12: Free the totals)", () => { + // the notice says what Free shows and what comes with Pro; it never says something "needs Pro" or offers a trial for analytics + const PAYWALL = /needs\s+Pro|trial for analytics/i; const KEYED = { "src/genclass.config.ts": `// genclass:init\nwindow.GENCLASS_CONFIG = { token: "${SHOP.token}" };\n` }; it("an app init made without an account: prints its private link from .genclass.local, says it is free, contacts nothing", () => { @@ -484,7 +485,8 @@ describe("dashboard: this app's free analytics, as a link in the terminal", () = const r = run(["dashboard", "--cwd", dir]); expect(r.code, r.out).toBe(0); expect(r.out).toMatch(new RegExp(`Your dashboard\\s+${ANON_LINK}`)); - expect(r.out).toContain("Free for every app"); + expect(r.out).toContain("Free shows this app's totals"); + expect(r.out.replace(/\s+/g, " ")).toContain("come with Pro (3 days free): https://genclass.dev/pricing"); expect(r.out).toContain("Keep this link private"); expect(r.out).not.toMatch(PAYWALL); expect(r.calls).toEqual([]); diff --git a/packages/runtime/test/install/cli.test.ts b/packages/runtime/test/install/cli.test.ts index c4be0fda..61b5a1e2 100644 --- a/packages/runtime/test/install/cli.test.ts +++ b/packages/runtime/test/install/cli.test.ts @@ -972,10 +972,11 @@ describe("init: app token and private dashboard link (fetch mocked; never the re expect(files[".gitignore"]).toBe("node_modules\n.genclass.local\n"); expect(r.out).toContain(LINK); expect(r.out).toContain("keep this link private; it is the only way to open your dashboard".replace(/^k/, "K")); - // analytics are free: the notice says so and how to get the link again, never that it needs Pro or a trial - expect(r.out).toContain("Free for every app"); + // the notice says what Free shows, that the rest comes with Pro and its 3 free days, and how to get the link again + expect(r.out).toContain("Free shows this app's totals"); + expect(r.out.replace(/\s+/g, " ")).toContain("come with Pro (3 days free)"); expect(r.out).toContain(`${CMD} dashboard`); - expect(r.out).not.toMatch(/needs\s+Pro|free trial|trial for analytics/i); + expect(r.out).not.toMatch(/needs\s+Pro|trial for analytics/i); // again: the token is already there, no network, nothing to do const r2 = cliEnv({ GENCLASS_TEST_FETCH: "ok", GENCLASS_TEST_FETCH_LOG: log }, dir, "init", "--yes", "--no-install"); expect(r2.code, r2.out).toBe(0); From e719e32eaef9e1b774bfaca3bbd6e7254a21552b Mon Sep 17 00:00:00 2001 From: Mehar Khanna Date: Thu, 12 Nov 2026 23:29:23 -0500 Subject: [PATCH 2/5] genclass: the note when no plan was chosen fits every command, not only the setup Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/actions.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/actions.mjs b/packages/cli/src/actions.mjs index d092f7ea..e39fcdd6 100644 --- a/packages/cli/src/actions.mjs +++ b/packages/cli/src/actions.mjs @@ -212,7 +212,7 @@ export async function projectPlan(offer, o = {}, deps = {}) { if (got) return "done"; // the setup goes on only once the app has its plan: Team, or Free with what stops the person from paying (the // owner's call, 2026-11-12) - note(`The setup goes on once ${name} has its plan: ${offer.needs ? offer.plan.name : `${offer.plan.name}, or ${offer.free.name} for now`}, on ${offer.checkoutUrl}`); + note(`${name} has no plan yet: ${offer.needs ? offer.plan.name : `${offer.plan.name}, or ${offer.free.name} for now`}, on ${offer.checkoutUrl}`); return "quit"; } From 7a289d485b8db702326e6cc61a1ed07fe81f6837 Mon Sep 17 00:00:00 2001 From: Mehar Khanna Date: Thu, 12 Nov 2026 23:45:59 -0500 Subject: [PATCH 3/5] WIP: Pro on the account again, 3 apps per $15 unit (plans.json v5), Free one app; every new app sees its plan page, a paying account continues with its plan, an app past the plan's apps adds more Co-Authored-By: Claude Opus 5.5 --- billing/plans.json | 20 +++--- packages/cli/src/actions.mjs | 36 ++++++++--- packages/cli/src/api.mjs | 1 + packages/cli/src/manage.mjs | 6 +- packages/cli/src/state.mjs | 4 +- telemetry-worker/src/auth.ts | 6 +- telemetry-worker/src/billing-pages.ts | 78 ++++++++++++++++------- telemetry-worker/src/billing.ts | 39 +++++++++++- telemetry-worker/src/cli.ts | 6 +- telemetry-worker/src/paid.ts | 9 +-- telemetry-worker/src/plans.ts | 90 +++++++++++++++++---------- telemetry-worker/src/stripe.ts | 16 +++-- 12 files changed, 211 insertions(+), 100 deletions(-) diff --git a/billing/plans.json b/billing/plans.json index d1baec05..9a4c8819 100644 --- a/billing/plans.json +++ b/billing/plans.json @@ -1,6 +1,6 @@ { - "_about": "The one place GenClass plans are defined. telemetry-worker/src/plans.ts imports this file (limits, entitlements, Stripe lookup keys, the billing page) and genclass.dev's site reads it (the pricing page). Three plans since version 3 (2026-11-11): Free, Team and Enterprise; since version 4 (2026-11-12) a plan belongs to a project, not to an account. Free is what finds, shows and fixes the problems: the open-source runtime with every mode and its on-device model, the coding-agent loop on the developer's machine, the dashboard's problems for an account's first 3 projects (the last 7 days shown; a 4th project on Free is locked until it has a plan), and 100 problems fixed live a month per project (limits.live_fixes_per_month; past it the runtime is told to observe only until the month ends) and genclass-dev[bot] on public repositories. Team is sold per project (per_project true: one Stripe subscription of quantity 1 per project, its metadata naming the project; everyone on the account's team sees it; telemetry-worker/src/stripe.ts) and adds what a team uses: genclass-dev[bot] on private repositories, visitor analytics, forwarding, export, 90 days of history and members. Enterprise is agreed by email: a null monthly price means the plan is not sold on the site, and an account gets it from GenClass's own grant (accounts.comp_until, set by hand to the end of the agreement; accounts.comp_plan names the plan granted, grant_plan when it is empty). The classifier a person from GenClass trains is in Enterprise only. retired_plans lists the plan ids GenClass sold before (Pro and Custom until 2026-10-19, Individual until 2026-10-22): their Stripe products and prices stay GenClass's own, so a subscription to one is still cancelled with its account, opens Manage billing and is reported to the owner while it bills, but it gives no plan (it reads as Free). The worker's Stripe code and scripts/stripe-setup.mjs sell any plan given a monthly price above 0, its stripe_lookup_keys and a stripe_tax_code. Prices are US cents (per project for a per_project plan, per seat for a per_seat plan); a null yearly price means no yearly option. limits.apps null means no plan limit (the worker still caps an account at 200 apps against abuse); limits.seats is how many people a plan that is not per_seat holds (null: no limit); limits.history_days is what the dashboard shows, and data is never deleted before 90 days whatever the plan, so the longer history is there the moment an account upgrades. Ingest never depends on the plan: an app's visitor analytics are counted while its plan leaves them out, and shown once it includes them. Pro (id team; named Team until 2026-11-12) has a trial: trial_days (3) days free at Checkout, a card taken up front and charged when it ends unless cancelled, once per account (telemetry-worker/src/billing.ts trialFor). Feature lines with available false are shown as coming next. The entitlement github_private is what lets genclass-dev[bot], the GitHub App, review an account's private repositories (telemetry-worker/src/github/entitlement.ts); public repositories are reviewed for everyone, whatever this file says. A self-hosted collector sets PLAN_GATES = \"open\" and gives every account the grant plan. managed_payments true would sell a plan through Stripe Managed Payments (only for fully automated digital products; telemetry-worker/README.md, Paid plans). `suggestions` (2026-11-12): what `genclass suggest` gives a plan (telemetry-worker/src/suggest.ts): `per_day`, model runs an account has a day, and `shown`, how many items of the plan it is shown (null: all); the measured checks themselves are free for everyone. Entitlements `problems` (the problem lists, pages, activity and fix prompts on the dashboard and in the genclass command; Free shows only the totals) and `ask` (genclass ask) were added 2026-11-12 (the owner's 'limit free fully'); a plan file without the key gives them.", - "version": 4, + "_about": "The one place GenClass plans are defined. telemetry-worker/src/plans.ts imports this file (limits, entitlements, Stripe lookup keys, the billing page) and genclass.dev's site reads it (the pricing page). Three plans since version 3 (2026-11-11): Free, Team and Enterprise; since version 4 (2026-11-12) a plan belongs to a project, not to an account. Free is what finds, shows and fixes the problems: the open-source runtime with every mode and its on-device model, the coding-agent loop on the developer's machine, the dashboard's problems for an account's first 3 projects (the last 7 days shown; a 4th project on Free is locked until it has a plan), and 100 problems fixed live a month per project (limits.live_fixes_per_month; past it the runtime is told to observe only until the month ends) and genclass-dev[bot] on public repositories. Team is sold per project (per_project true: one Stripe subscription of quantity 1 per project, its metadata naming the project; everyone on the account's team sees it; telemetry-worker/src/stripe.ts) and adds what a team uses: genclass-dev[bot] on private repositories, visitor analytics, forwarding, export, 90 days of history and members. Enterprise is agreed by email: a null monthly price means the plan is not sold on the site, and an account gets it from GenClass's own grant (accounts.comp_until, set by hand to the end of the agreement; accounts.comp_plan names the plan granted, grant_plan when it is empty). The classifier a person from GenClass trains is in Enterprise only. retired_plans lists the plan ids GenClass sold before (Pro and Custom until 2026-10-19, Individual until 2026-10-22): their Stripe products and prices stay GenClass's own, so a subscription to one is still cancelled with its account, opens Manage billing and is reported to the owner while it bills, but it gives no plan (it reads as Free). The worker's Stripe code and scripts/stripe-setup.mjs sell any plan given a monthly price above 0, its stripe_lookup_keys and a stripe_tax_code. Prices are US cents (per project for a per_project plan, per seat for a per_seat plan); a null yearly price means no yearly option. limits.apps null means no plan limit (the worker still caps an account at 200 apps against abuse); limits.seats is how many people a plan that is not per_seat holds (null: no limit); limits.history_days is what the dashboard shows, and data is never deleted before 90 days whatever the plan, so the longer history is there the moment an account upgrades. Ingest never depends on the plan: an app's visitor analytics are counted while its plan leaves them out, and shown once it includes them. Pro (id team; named Team until 2026-11-12) has a trial: trial_days (3) days free at Checkout, a card taken up front and charged when it ends unless cancelled, once per account (telemetry-worker/src/billing.ts trialFor). Feature lines with available false are shown as coming next. The entitlement github_private is what lets genclass-dev[bot], the GitHub App, review an account's private repositories (telemetry-worker/src/github/entitlement.ts); public repositories are reviewed for everyone, whatever this file says. A self-hosted collector sets PLAN_GATES = \"open\" and gives every account the grant plan. managed_payments true would sell a plan through Stripe Managed Payments (only for fully automated digital products; telemetry-worker/README.md, Paid plans). `suggestions` (2026-11-12): what `genclass suggest` gives a plan (telemetry-worker/src/suggest.ts): `per_day`, model runs an account has a day, and `shown`, how many items of the plan it is shown (null: all); the measured checks themselves are free for everyone. Entitlements `problems` (the problem lists, pages, activity and fix prompts on the dashboard and in the genclass command; Free shows only the totals) and `ask` (genclass ask) were added 2026-11-12 (the owner's 'limit free fully'); a plan file without the key gives them. Version 5 (2026-11-13, the owner's call): Pro is bought for the account again, not per app: one subscription, each unit of its quantity covers apps_per_unit (3) apps, oldest first, and a later app waits on its plan page (\"Add 3 more apps\"); a subscription bought per project under version 4 counts as one unit. Free covers one app. GenClass's grants cover every app.", + "version": 5, "currency": "usd", "contact": "mehar@genclass.dev", "grant_plan": "enterprise", @@ -10,13 +10,13 @@ { "id": "free", "name": "Free", - "summary": "The totals, one suggestion, up to 3 projects.", + "summary": "The totals and one suggestion, for one app.", "price_usd_cents": { "monthly": 0, "yearly": null }, "stripe_lookup_keys": { "monthly": null, "yearly": null }, "stripe_tax_code": null, "managed_payments": false, "per_seat": false, - "limits": { "apps": 3, "history_days": 7, "seats": 1, "live_fixes_per_month": 100 }, + "limits": { "apps": 1, "history_days": 7, "seats": 1, "live_fixes_per_month": 100 }, "suggestions": { "per_day": 3, "shown": 1 }, "entitlements": { "dashboard": true, "forwarding": false, "export": false, "analytics": false, "custom_model": false, "github_private": false, "problems": false, "ask": false }, "features": [ @@ -24,8 +24,8 @@ { "text": "On-device model", "available": true }, { "text": "Coding-agent loop on your machine (@genclass/mcp)", "available": true }, { "text": "Dashboard totals, last 7 days", "available": true }, - { "text": "Up to 3 projects", "available": true }, - { "text": "100 live fixes a month per project, then observe only", "available": true }, + { "text": "One app", "available": true }, + { "text": "100 live fixes a month, then observe only", "available": true }, { "text": "GitHub App on public repositories", "available": true }, { "text": "Page check", "available": true }, { "text": "Suggestions: your top one", "available": true }, @@ -35,7 +35,7 @@ { "id": "team", "name": "Pro", - "summary": "Everything for one project, your whole team included.", + "summary": "Everything for 3 apps, your whole team included.", "price_usd_cents": { "monthly": 1500, "yearly": null }, "stripe_lookup_keys": { "monthly": "genclass_team_monthly", "yearly": null }, "stripe_tax_code": "txcd_10103001", @@ -48,6 +48,7 @@ "features": [ { "text": "Everything in Free", "available": true }, { "text": "3 days free to start", "available": true }, + { "text": "3 apps; 3 more for each extra $15 a month", "available": true }, { "text": "Every problem, its pages and a fix prompt", "available": true }, { "text": "Ask about your app in plain words", "available": true }, { "text": "Every suggestion, plus the model's own ideas", "available": true }, @@ -58,10 +59,9 @@ { "text": "Alerts to Sentry, PostHog, Datadog, Slack or a webhook", "available": true }, { "text": "Data export and API", "available": true }, { "text": "90 days of history", "available": true }, - { "text": "Your whole team on the project", "available": true } + { "text": "Your whole team on it", "available": true } ], - "per_project": true, - "trial_days": 3 + "apps_per_unit": 3 }, { "id": "enterprise", diff --git a/packages/cli/src/actions.mjs b/packages/cli/src/actions.mjs index e39fcdd6..cd33f375 100644 --- a/packages/cli/src/actions.mjs +++ b/packages/cli/src/actions.mjs @@ -168,9 +168,13 @@ export async function planNow(o = {}, deps = {}, id = null) { return "none"; } const app = (state.projects ?? []).find((p) => (id ? p.id === id : p.token === state.app?.token) && !p.shared); - if (!app?.plan || app.plan.id !== "free" || app.plan.source || (app.planChosen && !app.needsPlan)) return "none"; - const offer = upgradeOffer(state, app.id); - if (!offer?.project) return "none"; + // version 5 (2026-11-13): every new app sees its plan page, a paying account's too (it goes on with its plan + // there), and an app its plan does not cover waits there for more + if (!app?.plan || !app.planUrl || (app.planChosen && !app.needsPlan)) return "none"; + const covered = !!app.plan.source && !app.needsPlan; + const base = upgradeOffer(state, app.id) ?? state.account?.offer ?? { plan: { name: "Pro" }, price: "", summary: null, features: [], free: { name: "Free", apps: null } }; + if (!covered && !state.account?.offer && !app.plan.source) return "none"; + const offer = { ...base, checkoutUrl: `${app.planUrl}&from=cli`, project: { id: app.id, name: app.name, token: app.token }, needs: app.needsPlan === true, current: covered ? app.plan.name : null, paying: !!app.plan.source }; if (o.json) return "open"; if (o.yes || !interactive()) { out(); @@ -201,8 +205,17 @@ export async function checkout(offer, o = {}, deps = {}) { */ export async function projectPlan(offer, o = {}, deps = {}) { const name = clean(offer.project.name, 30); - planCard(offer, offer.project.name); - note(offer.needs ? `Free covers ${offer.free.apps ?? 3} apps and this is one more: it needs ${offer.plan.name}, ${offer.price}.` : `${offer.plan.name} is ${offer.price}. Or try ${offer.free.name.toLowerCase()} for now (limited), on the same page.`); + if (offer.features?.length) planCard(offer, offer.project.name); + else out(); + note( + offer.current + ? `Your ${clean(offer.current, 20)} plan covers ${name}: continue with it on its plan page.` + : offer.needs && offer.paying + ? `Your plan covers no more apps: add ${offer.appsPerUnit ?? 3} more for ${name} on its plan page.` + : offer.needs + ? `Free covers ${offer.free.apps === 1 ? "one app" : `${offer.free.apps ?? 1} apps`} and this is one more: it needs ${offer.plan.name}, ${offer.price}.` + : `${offer.plan.name} is ${offer.price}. Or try ${offer.free.name.toLowerCase()} for now (limited), on the same page.`, + ); if (o.yes || !interactive()) { out(` Open ${c.cyan(offer.checkoutUrl)}`); if (openBrowser(offer.checkoutUrl, o)) note("Your browser is opening its plan page. Run npx genclass again once it is chosen."); @@ -212,7 +225,7 @@ export async function projectPlan(offer, o = {}, deps = {}) { if (got) return "done"; // the setup goes on only once the app has its plan: Team, or Free with what stops the person from paying (the // owner's call, 2026-11-12) - note(`${name} has no plan yet: ${offer.needs ? offer.plan.name : `${offer.plan.name}, or ${offer.free.name} for now`}, on ${offer.checkoutUrl}`); + note(offer.current ? `${name} goes on with ${clean(offer.current, 20)} once you continue on ${offer.checkoutUrl}` : `${name} has no plan yet: ${offer.needs ? offer.plan.name : `${offer.plan.name}, or ${offer.free.name} for now`}, on ${offer.checkoutUrl}`); return "quit"; } @@ -228,7 +241,7 @@ async function waitForPlan(offer, o = {}, deps = {}) { if (openBrowser(offer.checkoutUrl, o)) note(app ? `Your browser is opening the plan page for ${app}.` : "Your browser is opening the checkout."); const creds = readCredentials(); const stop = stopOnEnter(); - const sp = spinner(`${app && !offer.needs ? "Waiting for your choice in the browser" : "Waiting for the payment in the browser"}${canStop()}`); + const sp = spinner(`${offer.current || (app && !offer.needs) ? "Waiting for your choice in the browser" : "Waiting for the payment in the browser"}${canStop()}`); const until = Date.now() + (deps.waitMs ?? 10 * 60_000); let got = null; while (creds && !stop.signal.aborted && Date.now() < until) { @@ -238,13 +251,16 @@ async function waitForPlan(offer, o = {}, deps = {}) { if (!me.ok) continue; if (offer.project) { const p = me.projects.find((x) => x.token === offer.project.token); - if (p?.plan && (p.plan.id !== "free" || p.plan.source)) got = "paid"; - else if (p?.planChosen && !offer.needs) got = "free"; + // covered already: Continue was clicked; else a payment (or more apps) that covers it now, or Free chosen + if (offer.current) got = p?.planChosen ? "kept" : null; + else if (p?.plan && (p.plan.id !== "free" || p.plan.source) && !p.needsPlan) got = "paid"; + else if (p?.planChosen && !offer.needs && !p.needsPlan) got = "free"; } else if (me.plan && me.plan.id !== "free") got = "paid"; if (got) break; } await stop.done(); - if (got === "paid") sp.stop("ok", `${offer.plan.name} is active${app ? ` for ${app}` : ""}. Thank you.`); + if (got === "kept") sp.stop("ok", `${app} goes on with ${clean(offer.current, 20)}.`); + else if (got === "paid") sp.stop("ok", `${offer.plan.name} is active${app ? ` for ${app}` : ""}. Thank you.`); else if (got === "free") sp.stop("ok", `${app} is on ${offer.free.name} for now (limited).`); else sp.stop("warn", app && !offer.needs ? "No choice yet." : `No payment yet. ${offer.plan.name} starts the moment the payment goes through.`); return got; diff --git a/packages/cli/src/api.mjs b/packages/cli/src/api.mjs index 424ea56e..b6545043 100644 --- a/packages/cli/src/api.mjs +++ b/packages/cli/src/api.mjs @@ -80,6 +80,7 @@ function offerOf(o, host) { checkoutUrl, free: { name: str(f.name, 40) ?? "Free", apps: num(f.apps), historyDays: num(f.history_days), suggestionsShown: num(f.suggestions_shown), liveFixes: num(f.live_fixes) }, trialDays: num(o.trial_days), + appsPerUnit: num(o.apps_per_unit), }; } diff --git a/packages/cli/src/manage.mjs b/packages/cli/src/manage.mjs index 6873c8ef..0c10b6b9 100644 --- a/packages/cli/src/manage.mjs +++ b/packages/cli/src/manage.mjs @@ -83,7 +83,7 @@ export async function apps(o = {}, deps = {}) { const me = await fetchOverview(creds, deps).catch(() => null); const made = me?.ok ? me.projects.find((p) => p.token === r.token) : null; if (made?.needsPlan && made.planUrl) { - warn("Free covers 3 apps: this one needs a paid plan."); + warn(made.plan?.source ? "Your plan covers no more apps: this one needs more on it." : "Free covers one app: this one needs a paid plan."); out(` ${c.gray("Its plan:")} ${c.cyan(made.planUrl)}`); } else if (made?.plan?.id === "free" && made.planUrl) out(` ${c.gray("On Free (limited). Its plan:")} ${c.cyan(made.planUrl)}`); // its plan is chosen now, on its page (Pro, or Free with a reason) @@ -203,7 +203,7 @@ export async function plan(o = {}, deps = {}) { /** Free's limits in a few words, from the numbers the server sent ("3 apps, 100 live fixes a month"). */ const freeWords = (free) => - [free?.apps ? `${free.apps} apps` : null, free?.liveFixes ? `${free.liveFixes} live fixes a month` : null, free?.historyDays ? `${free.historyDays} days of history` : null].filter(Boolean).join(", "); + [free?.apps ? `${free.apps} app${free.apps === 1 ? "" : "s"}` : null, free?.liveFixes ? `${free.liveFixes} live fixes a month` : null, free?.historyDays ? `${free.historyDays} days of history` : null].filter(Boolean).join(", "); /** * The line after a command for an app on Free that can have a paid plan here: Free's limits and the way up. Printed @@ -221,7 +221,7 @@ export async function freeLine(o = {}, deps = {}) { const offer = upgradeOffer(state); if (!offer) return false; const lim = freeWords(offer.free); - const [first = "", ...rest] = wrap(`You are on Free, which has limits${lim ? ` (${lim})` : ""}. ${clean(offer.plan.name, 20)} has none, ${offer.trialDays ? `${offer.trialDays} days free, then ${clean(offer.price, 40)}` : `for ${clean(offer.price, 40)}`}. Upgrade now: npx genclass upgrade`, width() - 6); + const [first = "", ...rest] = wrap(`You are on Free, which has limits${lim ? ` (${lim})` : ""}. ${clean(offer.plan.name, 20)} has everything, ${offer.trialDays ? `${offer.trialDays} days free, then ${clean(offer.price, 40)}` : `for ${clean(offer.price, 40)}`}. Upgrade now: npx genclass upgrade`, width() - 6); out(); out(` ${c.yellow(sym.warn)} ${c.bold(first)}`); for (const l of rest) out(` ${l}`); diff --git a/packages/cli/src/state.mjs b/packages/cli/src/state.mjs index f25ba8a7..b82d9770 100644 --- a/packages/cli/src/state.mjs +++ b/packages/cli/src/state.mjs @@ -266,14 +266,14 @@ export async function readState(o = {}, deps = {}) { ? `${me.plan.name} plan` : "" : needs - ? `Free covers ${offer.free.apps ?? 3} apps: this one needs ${offer.plan.name}` + ? `Free covers ${offer.free.apps === 1 ? "one app" : `${offer.free.apps ?? 1} apps`}: this one needs ${offer.plan.name}` : choseFree ? `${offer.free.name} for now (limited) · ${offer.plan.name} is ${offer.price}` : `${offer.plan.name}, ${offer.price}`, why: !offer ? pp ? "This app's plan." : "The plan your account is on." : needs - ? `Free covers ${offer.free.apps ?? 3} apps and this is one more. ${offer.plan.name} is ${offer.price}.` + ? `Free covers ${offer.free.apps === 1 ? "one app" : `${offer.free.apps ?? 1} apps`} and this is one more. ${offer.plan.name} is ${offer.price}.` : `${offer.plan.name} is ${offer.price}. Or try ${offer.free.name} for now (limited).`, more: !offer ? "Plans and prices: genclass.dev/pricing." diff --git a/telemetry-worker/src/auth.ts b/telemetry-worker/src/auth.ts index 0313181c..75b11311 100644 --- a/telemetry-worker/src/auth.ts +++ b/telemetry-worker/src/auth.ts @@ -2110,9 +2110,11 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E const access = await projectAccess(c.db, p, env, c.now); // plans per project: the account's own app on Free, where the paid plan is sold, opens its plan page until one is // chosen there, Team or Free with what stops the person from paying (the owner's call, 2026-11-12) - if (mine && access.source === null && access.plan.id === FREE.id && PAID_PLANS[0]?.per_project === true && canBuy(env, a.account)) { + if (mine && PAID_PLANS.length && canBuy(env, a.account)) { + // version 5 (2026-11-13): the paywall for every new app, a paying account included (it clicks Continue with + // Pro), and for an app its plan does not cover const row = await c.db.prepare("SELECT plan_chosen FROM projects WHERE id = ?").bind(p.id).first<{ plan_chosen: string | null }>(); - if (!row?.plan_chosen) return redirect(`/account/billing?project=${encodeURIComponent(p.id!)}`); + if (!row?.plan_chosen || access.locked) return redirect(`/account/billing?project=${encodeURIComponent(p.id!)}`); } const base = { account: { apps, current: p.id!, email: a.account.email ?? (a.account.github_login ? `@${a.account.github_login}` : null), plan: access.plan.name }, diff --git a/telemetry-worker/src/billing-pages.ts b/telemetry-worker/src/billing-pages.ts index b60d7422..68b414b9 100644 --- a/telemetry-worker/src/billing-pages.ts +++ b/telemetry-worker/src/billing-pages.ts @@ -256,19 +256,26 @@ export function projectPlanPage(nonce: string, v: ProjectPlanView): string { const a = v.access; const paid = a.source === "paid"; const name = esc(v.project.name); + // version 5 (2026-11-13): Pro is the account's, 3 apps for each $15; every new app sees this page, a paying account + // included (Continue with Pro), and an app past what the plan covers waits here for more + const per = offer?.apps_per_unit ?? null; + const proFull = a.locked === "pro_apps"; + const covered = !a.locked && a.source !== null; const status = a.source === "open" - ? `This server gives every project everything in ${esc(a.plan.name)}.` - : paid - ? v.sub.cancels - ? `${name} is on ${esc(a.plan.name)} until ${day(v.sub.periodEnd)}. It is cancelled and will not renew; you can resume it in Manage billing.` - : v.sub.status === "past_due" - ? `${name} is on ${esc(a.plan.name)}. The last payment did not go through: update your card in Manage billing.` - : `${name} is on ${esc(a.plan.name)}${v.sub.periodEnd ? `. It renews on ${day(v.sub.periodEnd)}` : ""}.` - : a.source === "comp" - ? `${name} has ${esc(a.plan.name)}, set up by GenClass. You can start paying for it any time.` - : a.locked - ? `${name} needs a plan: Free covers ${FREE_APPS} projects, and this one is past them.` - : `${name} is on Free.`; + ? `This server gives every app everything in ${esc(a.plan.name)}.` + : proFull + ? `Your ${esc(offer?.name ?? "paid")} plan covers ${a.apps ?? 0} apps, and ${name} is one more.${per ? ` Add ${per} more apps for ${esc(priceText(offer!).replace(/ for \d+ apps$/, ""))}.` : ""}` + : paid + ? v.sub.cancels + ? `${name} is on ${esc(a.plan.name)} until ${day(v.sub.periodEnd)}. It is cancelled and will not renew; you can resume it in Manage billing.` + : v.sub.status === "past_due" + ? `${name} is on ${esc(a.plan.name)}. The last payment did not go through: update your card in Manage billing.` + : `Your ${esc(a.plan.name)} plan covers ${name}${a.apps ? ` (${a.apps} apps on your account)` : ""}.` + : a.source === "comp" + ? `Your ${esc(a.plan.name)} plan, set up by GenClass, covers ${name}.` + : a.locked + ? `Free covers ${FREE_APPS === 1 ? "one app" : `${FREE_APPS} apps`}, and ${name} is one more: it needs ${esc(offer?.name ?? "a paid plan")}.` + : `${name} is on Free.`; const usage = v.usage && v.usage.limit !== null ? `

${v.usage.observe_only ? `Observing only. ` : ""}Problems fixed live this month: ${v.usage.used} of ${v.usage.limit}.${v.usage.observe_only ? ` GenClass keeps finding and reporting every problem, and stops fixing them on visitors' screens until ${day(v.usage.resets)}, or until this project has ${esc(offer?.name ?? "a paid plan")}.` : ""}

` : ""; @@ -281,9 +288,16 @@ export function projectPlanPage(nonce: string, v: ProjectPlanView): string { : ""; const test = v.testMode && v.live ? '

Stripe test mode. No real money moves. Pay with card 4242 4242 4242 4242, any future date and any CVC.

' : ""; const features = (offer?.features ?? []).filter((f) => f.available).map((f) => `
  • ${esc(f.text)}
  • `).join(""); - const card = offer && !paid && a.source !== "open" - ? `

    ${esc(offer.name)}

    ${esc(priceText(offer))}

    ${esc(offer.summary)}

      ${features}
    -
    ${v.live ? `${v.trialDays ? `

    ${v.trialDays} days free, then ${esc(priceText(offer))} for ${name}. Cancel before the trial ends and you pay nothing.

    ` : ""}` : 'Paid plans are not on sale yet.'}
    + const action = covered + ? `

    Your current plan.${paid ? ` Manage billing` : ""}

    ` + : proFull + ? `

    ${esc(priceText(offer!).replace(/ for \d+ apps$/, ""))} more, on your next bill. You choose the quantity on Stripe.

    ` + : v.live + ? `${v.trialDays ? `

    ${v.trialDays} days free, then ${esc(priceText(offer!))}. Cancel before the trial ends and you pay nothing.

    ` : ""}` + : 'Paid plans are not on sale yet.'; + const card = offer && a.source !== "open" + ? `

    ${esc(offer.name)}${covered ? ' Your plan' : ""}

    ${esc(priceText(offer))}

    ${esc(offer.summary)}

      ${features}
    +
    ${action}
    ${a.locked || a.source !== null ? "" : `

    Try for free (limited)

    @@ -295,24 +309,24 @@ export function projectPlanPage(nonce: string, v: ProjectPlanView): string {
    `}` - : paid - ? `
    ` + : a.source === "open" + ? `
    ` : ""; const body = `
    -

    ${paid || a.source === "comp" || a.source === "open" ? `The plan for ${name}` : `Choose a plan for ${name}`}

    +

    Choose a plan for ${name}

    ${status}

    ${usage}
    ${notice}${test}
    ${card} - +

    All your projects and their plans

    -`; +`; const script = ` (function(){ gcDropQuery(["project"]); @@ -347,10 +361,26 @@ ${notice}${test} }); } var portal = document.getElementById("portal"); - if (portal) portal.addEventListener("click", function(){ + if (portal) portal.addEventListener("click", function(e){ + e.preventDefault(); var err = document.getElementById("portalerr"); err.textContent = ""; - gcBusy(portal, true, "Opening Stripe..."); - gcPost("/api/billing/portal", {}).then(function(j){ location.href = j.url; }, function(e){ err.textContent = gcWhy(e); gcBusy(portal, false); }); + gcPost("/api/billing/portal", {}).then(function(j){ location.href = j.url; }, function(e2){ err.textContent = gcWhy(e2); }); + }); + // a paying account goes on with its plan: the app's dashboard (its setup for a new one) + var cont = document.querySelector("[data-continue]"); + if (cont) cont.addEventListener("click", function(){ + var err = document.getElementById("planerr"); err.textContent = ""; + gcBusy(cont, true, "One moment..."); + gcPost("/api/billing/projects/" + encodeURIComponent(cfg.project) + "/continue", {}).then(function(){ + location.href = "/account/apps/" + encodeURIComponent(cfg.project) + (cfg.isNew || cfg.cli ? "#connect" : ""); + }, function(e){ err.textContent = gcWhy(e); gcBusy(cont, false); }); + }); + // more apps on Pro: the quantity is changed on Stripe's billing page, and this page waits for it + var add = document.querySelector("[data-addapps]"); + if (add) add.addEventListener("click", function(){ + var err = document.getElementById("planerr"); err.textContent = ""; + gcBusy(add, true, "Opening Stripe..."); + gcPost("/api/billing/portal", {}).then(function(j){ location.href = j.url; }, function(e){ err.textContent = gcWhy(e); gcBusy(add, false); }); }); if (cfg.wait) { var tries = 0; @@ -358,7 +388,7 @@ ${notice}${test} if (++tries > 40) { clearInterval(timer); return; } fetch("/api/billing?project=" + encodeURIComponent(cfg.project), { headers: { accept: "application/json" }, cache: "no-store", credentials: "same-origin" }) .then(function(r){ return r.ok ? r.json() : null; }) - .then(function(j){ if (j && j.project && j.project.source === "paid") { clearInterval(timer); location.reload(); } }) + .then(function(j){ if (j && j.project && j.project.source === "paid" && !j.project.locked) { clearInterval(timer); location.reload(); } }) .catch(function(){}); }, 3000); } diff --git a/telemetry-worker/src/billing.ts b/telemetry-worker/src/billing.ts index 844e475f..97ac6aa8 100644 --- a/telemetry-worker/src/billing.ts +++ b/telemetry-worker/src/billing.ts @@ -260,6 +260,8 @@ export async function routeBilling(request: Request, url: URL, path: string, env } const free = /^\/api\/billing\/projects\/([^/]+)\/free$/.exec(path); if (free) return m === "POST" ? chooseFree(c, f, free[1]!) : method(); + const keep = /^\/api\/billing\/projects\/([^/]+)\/continue$/.exec(path); + if (keep) return m === "POST" ? continueWith(c, keep[1]!) : method(); if (integ) return integrations(c, f, integ[1]!, integ[2] ?? null, !!integ[3]); if (events) return exportEvents(c, events[1]!); if (keys) return exportKeys(c, keys[1] ?? null); @@ -268,6 +270,23 @@ export async function routeBilling(request: Request, url: URL, path: string, env // --------------------------------------------------------------------------------------------- Checkout, portal +/** + * POST /api/billing/projects//continue: an app the account's plan covers goes on with it (the plan page's + * "Continue with Pro", the owner's call 2026-11-13: every new app sees the paywall, a paying account clicks through). + * It writes when (projects.plan_chosen), which the genclass command and the app's page wait for. 409 when the plan + * does not cover the app (it needs Pro, or more apps on Pro). + */ +async function continueWith(c: C, id: string): Promise { + const a = await browserWrite(c, "a plan is chosen"); + if (a instanceof Response) return a; + const p = await ownedProject(c, a.account.id, id); + if (!p || !p.id) return fail(404, "not found"); + const access = await projectAccess(c.db, p, c.env, c.now); + if (!access.source || access.locked) return fail(409, `${p.name} needs a plan first`); + await c.db.prepare("UPDATE projects SET plan_chosen = ? WHERE id = ? AND account_id = ? AND plan_chosen IS NULL").bind(c.now.toISOString(), p.id, a.account.id).run(); + return json(200, { ok: true, plan: access.plan.id }); +} + /** What can stop someone from paying, as the plan page's popup lists them: [value, the words shown]. */ export const FREE_REASONS: readonly (readonly [string, string])[] = [ ["price", "It costs too much"], @@ -354,17 +373,28 @@ async function checkout(c: C, f: Fetch): Promise { if (typeof body.project_id !== "string" || !body.project_id) return fail(400, `the ${plan.name} plan is bought for one project: send project_id`); return checkoutProject(c, f, a, plan, body.project_id); } - // a plan sold per seat: how many people (the owner included); one when the page sends none + // a plan sold per seat: how many people (the owner included); one when the page sends none. A plan sold by units of + // apps (version 5, Pro): how many units, by default enough for the apps the account has let seats = 1; if (plan.per_seat === true && body.seats !== undefined) { const n = cleanSeats(body.seats); if (n === null) return fail(400, `seats is a whole number from 1 to ${MAX_SEATS}; for a larger team email ${PLANS_FILE.contact}`); seats = n; } + if (plan.apps_per_unit) { + const asked = body.quantity ?? body.seats; + const own = (await accountProjects(c, a.account.id)).filter((x) => x.account_id === a.account.id).length; + const n = asked === undefined ? Math.max(1, Math.ceil(own / plan.apps_per_unit)) : cleanSeats(asked); + if (n === null) return fail(400, `quantity is a whole number from 1 to ${MAX_SEATS}`); + seats = n; + } + // started from an app's plan page: Stripe returns there + const backTo = typeof body.project_id === "string" && (await ownedProject(c, a.account.id, body.project_id))?.id ? body.project_id : undefined; if (await overBudget(c.env.AUTH_LIMIT, "checkout", a.account.id)) return tooMany(); // as stored: ensureCustomer decides what to do with a customer from the other Stripe mode const account = { id: a.account.id, email: a.account.email, ...(await accountBilling(c.db, a.account.id)) }; try { + let first = false; let customer = await ensureCustomer(c.env, f, c.db, account); if (!customer.fresh) { // the stored customer must be the account's own GenClass customer, holding nothing else; then what Stripe has @@ -374,6 +404,7 @@ async function checkout(c: C, f: Fetch): Promise { if (own.state === "not_genclass") return refuseShared(c, f, account.id, customer.id, NOT_GENCLASS); if (own.state === "ok" && sharedCustomer(own.subs)) return refuseShared(c, f, account.id, customer.id, SHARED_SUBSCRIPTION); const subs = own.state === "ok" ? own.subs : []; + first = own.state === "missing" || subs.length === 0; if (own.state === "missing") { customer = await ensureCustomer(c.env, f, c.db, { ...account, stripe_customer_id: customer.id, stripe_mode: stripeMode(c.env) }, customer.id); } @@ -400,8 +431,10 @@ async function checkout(c: C, f: Fetch): Promise { return json(200, { url, switch: true }); } } - const url = await checkoutSession(c.env, f, { account, customer: customer.id, plan, origin: c.cfg.origin, now: c.now, fresh: customer.fresh, seats }); - return json(200, { url }); + // the first subscription starts with the plan's trial (trialFor), once per account + const trialDays = customer.fresh || first ? await trialFor(c.db, a.account.id, plan, 0) : 0; + const url = await checkoutSession(c.env, f, { account, customer: customer.id, plan, origin: c.cfg.origin, now: c.now, fresh: customer.fresh, seats, trialDays, backTo }); + return json(200, { url, trial_days: trialDays || null }); } catch (e) { return stripeFail(e); } diff --git a/telemetry-worker/src/cli.ts b/telemetry-worker/src/cli.ts index bc654e38..7cf9eba3 100644 --- a/telemetry-worker/src/cli.ts +++ b/telemetry-worker/src/cli.ts @@ -86,7 +86,7 @@ async function withPlans(c: C, accountId: string): Promise { const j: Record = { ...projectJson(c, p) }; const a = p.account_id === accountId ? access.get(p.token) : undefined; - if (a) Object.assign(j, { plan: { id: a.plan.id, name: a.plan.name, source: a.source }, needs_plan: a.locked === "free_projects", plan_chosen: chosen.has(p.token), plan_url: `${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}` }); + if (a) Object.assign(j, { plan: { id: a.plan.id, name: a.plan.name, source: a.source }, needs_plan: !!a.locked, plan_chosen: chosen.has(p.token), plan_url: `${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}` }); return j; }); } @@ -104,7 +104,8 @@ async function offerFor(c: C, account: { id: string; email: string | null }, acc // command asks right after the app is in the account); one sold for the whole account, while the account is on Free const open = (projects ?? []).filter((x) => x.row.id && x.access.plan.id === FREE.id && x.access.source === null); if (plan.per_project === true && projects ? !open.length : access.plan.id !== FREE.id || access.source !== null) return null; - const one = plan.per_project === true && open.length === 1 ? open[0]!.row.id! : null; + // the page of the account's first app with no plan: Pro is bought there, for the account (version 5) + const one = open.length ? open[0]!.row.id! : null; return { plan: { id: plan.id, name: plan.name }, price: priceText(plan), @@ -112,6 +113,7 @@ async function offerFor(c: C, account: { id: string; email: string | null }, acc features: plan.features.filter((f) => f.available && !/^Everything in /i.test(f.text)).map((f) => f.text).slice(0, 12), checkout_url: one ? `${c.cfg.origin}/account/billing?project=${encodeURIComponent(one)}&from=cli` : `${c.cfg.origin}/account/billing?plan=${plan.id}&start=1`, trial_days: (await trialFor(c.db, account.id, plan)) || null, + apps_per_unit: plan.apps_per_unit ?? null, free: { name: FREE.name, apps: FREE.limits.apps, history_days: FREE.limits.history_days, suggestions_shown: suggestionLimits(FREE).shown, live_fixes: FREE.limits.live_fixes_per_month ?? null }, }; } diff --git a/telemetry-worker/src/paid.ts b/telemetry-worker/src/paid.ts index 00baca74..57e7e4a0 100644 --- a/telemetry-worker/src/paid.ts +++ b/telemetry-worker/src/paid.ts @@ -9,7 +9,7 @@ import type { BucketLike, Env } from "./collector.js"; import { RETENTION_DAYS, type D1Like } from "./dashboard.js"; import { storeForExport } from "./export.js"; import { forwardBatch } from "./forward.js"; -import { ACCESS_COLUMNS, accessOf, accountWide, better, entitled, FREE, keepDays, planGatesOpen, projectOwnAccess, stripeMode, type AccessRow, type Plan, type StripeMode } from "./plans.js"; +import { ACCESS_COLUMNS, accessIn, accessOf, coverage, entitled, FREE, keepDays, planGatesOpen, stripeMode, type AccessRow, type Plan, type StripeMode } from "./plans.js"; type Fetch = (input: string, init?: RequestInit) => Promise; @@ -49,11 +49,8 @@ export async function ownerOf(db: D1Like, token: string, nowMs: number, mode: St const now = new Date(nowMs); // the project's own plan or grant, or a subscription of its whole account from before plans were per project // (src/plans.ts projectAccess; Free has neither export nor forwarding, so the 3-project lock does not matter here) - const plan = !row?.account_id - ? FREE - : open - ? accessOf(row, mode, now, true).plan - : better(projectOwnAccess({ id: row.id, token, created: row.created, plan: row.p_plan, stripe_mode: row.p_mode, comp_until: row.p_comp, comp_plan: row.p_comp_plan }, mode, now), accountWide(row, mode, now)).plan; + // the account's plan when it covers this app (src/plans.ts coverage, version 5), else Free's + const plan = !row?.account_id ? FREE : open ? accessOf(row, mode, now, true).plan : accessIn(await coverage(db, row.account_id, mode, now), { token }).plan; const o: Owner | null = row ? { projectId: row.id, name: row.name, accountId: row.account_id, plan, destinations: Number(row.n) || 0 } : null; if (owners.size > 5000) owners.clear(); owners.set(token, { o, exp: nowMs + OWNER_MS }); diff --git a/telemetry-worker/src/plans.ts b/telemetry-worker/src/plans.ts index ac29b577..9df23c6b 100644 --- a/telemetry-worker/src/plans.ts +++ b/telemetry-worker/src/plans.ts @@ -76,6 +76,11 @@ export interface Plan { per_project?: boolean; /** Days free at Checkout before the first payment (Pro: 3), once per account; left out: no trial. */ trial_days?: number; + /** + * Version 5: the apps one unit of the subscription covers (Pro: 3). The quantity is the units (accounts.seats), so + * the account's first apps_per_unit x quantity apps have the plan; a later one waits for more (coverage). + */ + apps_per_unit?: number; /** * `seats`: the people a plan that is not per_seat holds, the owner included (null or left out: no limit). `apps` * on Free: how many of an account's projects it covers (a later one is locked until it has a plan). @@ -201,7 +206,7 @@ export function priceText(plan: Plan): string { const c = plan.price_usd_cents.monthly; if (c === null) return "By agreement"; if (!c) return "$0"; - return `$${c % 100 ? (c / 100).toFixed(2) : String(c / 100)}${plan.per_project === true ? " / project" : plan.per_seat === true ? " / developer" : ""} / month`; + return `$${c % 100 ? (c / 100).toFixed(2) : String(c / 100)}${plan.per_project === true ? " / project" : plan.per_seat === true ? " / developer" : ""} / month${plan.apps_per_unit ? ` for ${plan.apps_per_unit} apps` : ""}`; } /** The most seats one Checkout sells and one account holds on a per_seat plan; a larger team is agreed by email. */ @@ -279,8 +284,13 @@ export interface Access { source: AccessSource | null; /** How many people the account holds now, its owner included (seatsOf); null: no limit. */ seats: number | null; - /** A project on Free past the account's first limits.apps free projects: no dashboard until it has a plan. */ - locked?: "free_projects"; + /** + * An app past the ones its account's plan covers (coverage): on Free past Free's apps, or on Pro past 3 x the units + * paid for. No dashboard until the account's plan covers it. + */ + locked?: "free_projects" | "pro_apps"; + /** How many apps the account's plan covers (coverage); null: no limit. */ + apps?: number | null; } /** The columns accountAccess reads (accounts, migrations 0004, 0006 and 0014). */ @@ -394,6 +404,8 @@ export async function accountPlan(db: D1Like, accountId: string, env: Env, now: /** This access opens dashboards (every plan in billing/plans.json today), unless the project waits for a plan. */ export const hasDashboard = (a: Access): boolean => entitled(a.plan, "dashboard") && !a.locked; +/** A plan sold by quantity: seats (per_seat) or units of apps (apps_per_unit). */ +export const byQuantity = (plan: Plan): boolean => plan.per_seat === true || (Number.isInteger(plan.apps_per_unit) && plan.apps_per_unit! > 0); /** This access shows visitor analytics (Team and Enterprise); without it the stats are served with them left out. */ export const hasAnalytics = (a: Access): boolean => entitled(a.plan, "analytics"); @@ -409,46 +421,60 @@ export const problemsLock = (upgradeUrl: string): { plan: string | null; upgrade /** The first plan on sale with an entitlement a plan file can leave out (problems, ask): for "comes with Pro". */ export const firstPaidWith = (e: "problems" | "ask"): Plan | null => PAID_PLANS.find((p) => p.entitlements[e] !== false) ?? null; -/** The access behind an app: its account's, or Free's for an app known only by its private link. */ +/** What an account's plan covers: its access, how many of its apps (null: all) and its apps, oldest first. */ +export interface Coverage { + access: Access; + cap: number | null; + rows: ProjectPlanRow[]; +} + +/** + * The account's plan and how many apps it covers (billing/plans.json version 5): the best of its own subscription or + * grant and every app's (a subscription bought per project under version 4, a grant copied to an app). A paid plan + * sold by units (apps_per_unit) covers apps_per_unit x the units: the account's quantity (accounts.seats) plus one for + * each app's own subscription. A grant covers every app, Free its limits.apps. + */ +export async function coverage(db: D1Like, accountId: string, mode: StripeMode | null, now: Date): Promise { + const acct = await db.prepare(`SELECT ${ACCESS_COLUMNS} FROM accounts WHERE id = ?`).bind(accountId).first(); + const rows = (await db.prepare(`SELECT ${PROJECT_PLAN_COLUMNS} FROM projects WHERE account_id = ? ORDER BY created, rowid`).bind(accountId).all()).results ?? []; + let best = accessOf(acct, mode, now); + let units = best.source === "paid" ? Math.max(1, cleanSeats(acct?.seats) ?? 1) : 0; + for (const r of rows) { + const own = projectOwnAccess(r, mode, now); + if (own.source === "paid") units++; + best = better(best, own); + } + const per = best.plan.apps_per_unit; + const cap = best.source === "comp" || best.source === "open" ? null : best.source === "paid" ? (per ? per * Math.max(1, units) : (best.plan.limits.apps ?? null)) : (FREE.limits.apps ?? null); + return { access: { ...best, apps: cap }, cap, rows }; +} + +/** One app's access under its account's coverage: the plan when the app is among the first `cap`, else locked. */ +export function accessIn(cov: Coverage, project: { token?: string; id?: string | null }): Access { + const i = cov.rows.findIndex((r) => (project.token ? r.token === project.token : r.id === project.id)); + if (i < 0 || cov.cap === null || i < cov.cap) return cov.access; + return { ...NO_ACCESS, apps: cov.cap, locked: cov.access.source === null ? "free_projects" : "pro_apps" }; +} + /** * Every app of an account with its access (projectAccess's rule, in one pass): its own plan or grant, its account's * subscription from before plans were per project, and the lock on a project past the ones Free covers. In the * order the apps were created. */ export async function accountProjectsAccess(db: D1Like, accountId: string, env: Env, now: Date): Promise<{ row: ProjectPlanRow; access: Access }[]> { - const rows = (await db.prepare(`SELECT ${PROJECT_PLAN_COLUMNS} FROM projects WHERE account_id = ? AND id IS NOT NULL ORDER BY created, rowid`).bind(accountId).all()).results ?? []; - if (planGatesOpen(env)) return rows.map((row) => ({ row, access: OPEN_ACCESS })); - const mode = stripeMode(env); - const acct = await db.prepare(`SELECT ${ACCESS_COLUMNS} FROM accounts WHERE id = ?`).bind(accountId).first(); - const legacy = accountWide(acct, mode, now); - const cap = FREE.limits.apps; - let free = 0; - return rows.map((row) => { - const access = better(projectOwnAccess(row, mode, now), legacy); - if (access.plan.id !== FREE.id) return { row, access }; - free++; - return { row, access: cap !== null && free > cap ? { ...access, locked: "free_projects" as const } : access }; - }); + if (planGatesOpen(env)) { + const rows = (await db.prepare(`SELECT ${PROJECT_PLAN_COLUMNS} FROM projects WHERE account_id = ? AND id IS NOT NULL ORDER BY created, rowid`).bind(accountId).all()).results ?? []; + return rows.map((row) => ({ row, access: OPEN_ACCESS })); + } + const cov = await coverage(db, accountId, stripeMode(env), now); + return cov.rows.filter((row) => row.id).map((row) => ({ row, access: accessIn(cov, row) })); } +/** The access behind an app: its account's plan when the plan covers it (coverage), Free's for an app with no account. */ export async function projectAccess(db: D1Like, project: { account_id?: string | null; token?: string; id?: string | null }, env: Env, now: Date): Promise { if (planGatesOpen(env)) return OPEN_ACCESS; if (!project.account_id) return NO_ACCESS; - const mode = stripeMode(env); - // a subscription of the whole account from before plans were per project, and an account-wide grant (accountWide), - // cover every one of its apps - const acct = await db.prepare(`SELECT ${ACCESS_COLUMNS} FROM accounts WHERE id = ?`).bind(project.account_id).first(); - const legacy = accountWide(acct, mode, now); - const rows = (await db.prepare(`SELECT ${PROJECT_PLAN_COLUMNS} FROM projects WHERE account_id = ? ORDER BY created, rowid`).bind(project.account_id).all()).results ?? []; - const mine = rows.find((r) => (project.token ? r.token === project.token : r.id === project.id)); - const of = (r: ProjectPlanRow): Access => better(projectOwnAccess(r, mode, now), legacy); - const access = mine ? of(mine) : legacy; - if (access.plan.id !== FREE.id || !mine) return access; - // Free covers the account's first limits.apps projects on Free, oldest first; a later one waits for a plan - const cap = FREE.limits.apps; - if (cap === null) return access; - const free = rows.filter((r) => of(r).plan.id === FREE.id); - return free.findIndex((r) => r.token === mine.token) >= cap ? { ...access, locked: "free_projects" } : access; + return accessIn(await coverage(db, project.account_id, stripeMode(env), now), project); } /** The first plan in plans.json that includes `e` (the plan an upgrade message names), or null. */ diff --git a/telemetry-worker/src/stripe.ts b/telemetry-worker/src/stripe.ts index 520f4fc3..0092c895 100644 --- a/telemetry-worker/src/stripe.ts +++ b/telemetry-worker/src/stripe.ts @@ -74,7 +74,7 @@ import type { Env } from "./collector.js"; import { randomBase62, sha256Hex, type D1Like } from "./dashboard.js"; import { json } from "./http.js"; -import { cleanSeats, entitled, LIVE_STATUSES, LOOKUP_PREFIX, MAX_SEATS, ownedPrice, pastDueLapsed, planById, PLANS, PLANS_FILE, planForPrice, stripeMode, type Billing, type Plan, type PlanId } from "./plans.js"; +import { byQuantity, cleanSeats, entitled, LIVE_STATUSES, LOOKUP_PREFIX, MAX_SEATS, ownedPrice, pastDueLapsed, planById, PLANS, PLANS_FILE, planForPrice, stripeMode, type Billing, type Plan, type PlanId } from "./plans.js"; import { equalStrings } from "./webauthn.js"; export const STRIPE_API = "https://api.stripe.com"; @@ -404,14 +404,16 @@ export function managedPayments(env: Env, plan: Plan): boolean { export async function checkoutSession( env: Env, f: Fetch, - o: { account: BillingAccount; customer: string; plan: Plan; origin: string; now: Date; fresh?: boolean; switchFrom?: string; seats?: number; project?: { id: string }; trialDays?: number }, + o: { account: BillingAccount; customer: string; plan: Plan; origin: string; now: Date; fresh?: boolean; switchFrom?: string; seats?: number; project?: { id: string }; trialDays?: number; backTo?: string }, ): Promise { // free days only on a first subscription (the caller decides), never on a switch, and only what Stripe allows (1 to 730) const trialDays = !o.switchFrom && Number.isInteger(o.trialDays) && o.trialDays! >= 1 && o.trialDays! <= 730 ? o.trialDays! : 0; const lookup = o.plan.stripe_lookup_keys.monthly; if (!lookup) throw new StripeError(`the ${o.plan.name} plan has no monthly price`, 400); // a plan sold per seat: the quantity is the seats asked for, and the buyer can still change it on Stripe's page - const perSeat = o.plan.per_seat === true; + // a plan sold by quantity: seats (per_seat) or units of apps (apps_per_unit, Pro since version 5); the buyer can still + // change it on Stripe's page + const perSeat = byQuantity(o.plan); const seats = perSeat ? (cleanSeats(o.seats ?? 1) ?? 1) : 1; if (o.switchFrom !== undefined && !idOf(o.switchFrom)) throw new StripeError("not a subscription id", 400); const closed: string[] = []; @@ -437,7 +439,9 @@ export async function checkoutSession( if (o.switchFrom) mark.genclass_switch_from = o.switchFrom; // a plan per project: the subscription names its project, and the webhook writes it there (applyProjectSubscription) if (o.project) mark.genclass_project = o.project.id; - const back = o.project ? `${o.origin}/account/billing?project=${encodeURIComponent(o.project.id)}` : `${o.origin}/account/billing`; + // a project's checkout (version 4), or the account's started from an app's plan page (backTo), returns to that page + const backId = o.project?.id ?? o.backTo; + const back = backId ? `${o.origin}/account/billing?project=${encodeURIComponent(backId)}` : `${o.origin}/account/billing`; // a double click within the same minute gets the same session; after closing pages, a new key (the old session // under the plain key may be one of those just closed); a switch has its own const after = closed.length ? `-after-${(await sha256Hex(closed.join(","))).slice(0, 16)}` : ""; @@ -455,7 +459,7 @@ export async function checkoutSession( client_reference_id: o.account.id, line_items: [perSeat ? { price, quantity: seats, adjustable_quantity: { enabled: true, minimum: 1, maximum: MAX_SEATS } } : { price, quantity: 1 }], // back from a switch, the page waits for the new plan - success_url: o.project ? `${back}&done=1` : `${o.origin}/account/billing?done=1${o.switchFrom ? `&to=${o.plan.id}` : ""}`, + success_url: backId ? `${back}&done=1` : `${o.origin}/account/billing?done=1${o.switchFrom ? `&to=${o.plan.id}` : ""}`, cancel_url: back, metadata: { ...mark, genclass_plan: o.plan.id, ...(perSeat ? { genclass_seats: String(seats) } : {}), ...(trialDays ? { genclass_trial_days: String(trialDays) } : {}) }, // a card (or another payment method) up front @@ -659,7 +663,7 @@ export function subscriptionState(sub: Obj, o: { pastDueSince?: string | null; n lookupKey, periodEnd: isoOf(end), cancelAtPeriodEnd: status !== "canceled" && (sub?.cancel_at_period_end === true || typeof sub?.cancel_at === "number"), - seats: priced?.per_seat === true ? (cleanSeats(quantity) ?? (typeof quantity === "number" && quantity > MAX_SEATS ? MAX_SEATS : 1)) : null, + seats: priced && byQuantity(priced) ? (cleanSeats(quantity) ?? (typeof quantity === "number" && quantity > MAX_SEATS ? MAX_SEATS : 1)) : null, }; } From de04285710ae4f3fb8d7425df65a5e85802481be Mon Sep 17 00:00:00 2001 From: Mehar Khanna Date: Fri, 13 Nov 2026 00:18:51 -0500 Subject: [PATCH 4/5] The genclass command comes with Pro; Pro per developer for up to 3 apps (plans.json v5) The owner's calls of 2026-11-13: "let's make the entire CLI paid", Pro per developer with up to 3 apps on the account (more is Enterprise), Free one app on the website. - genclass: Pro is the step right after the sign-in (paywall.mjs): the card, Enter opens the account's checkout with its 3-day trial and waits for the payment. Every command but login, logout, whoami, plan, upgrade, profile, status, remove, help and version asks first; signed out, the sign-in comes first. No terminal: one line with the checkout, exit code 1. A 402 needs_pro from the server says the same line everywhere. - Collector: /api/cli routes but the overview and the account answer 402 needs_pro to a CLI session of an account with no plan and no paying team (cliPaid); overview and account carry `cli`. Browser sessions, servers that sell nothing and PLAN_GATES open are never locked. - plans.json v5: Pro per_seat, limits.apps 3, no apps_per_unit; Free's lines in the owner's words (one app, totals, open-source basics, public GitHub App, page check, 100 live fixes). An app past Pro's 3 gets "Email us about Enterprise"; an app with its own version-4 subscription is never locked; lock messages name the app's plan page. - Tests: collector 840, CLI 187, runtime install 157. Co-Authored-By: Claude Opus 5.5 --- billing/plans.json | 28 +- packages/cli/CHANGELOG.md | 27 +- packages/cli/README.md | 21 +- packages/cli/src/actions.mjs | 61 +- packages/cli/src/api.mjs | 33 +- packages/cli/src/cli.mjs | 60 +- packages/cli/src/manage.mjs | 64 +- packages/cli/src/paywall.mjs | 156 +++++ packages/cli/src/session.mjs | 15 +- packages/cli/src/setup.mjs | 15 +- packages/cli/src/state.mjs | 128 ++-- packages/cli/src/tui.mjs | 4 +- packages/cli/test/actions.test.ts | 2 +- packages/cli/test/server-account.test.ts | 77 ++- packages/cli/test/server.test.ts | 6 +- packages/cli/test/state.test.ts | 200 ++++-- packages/cli/test/suggest.test.ts | 2 +- telemetry-worker/README.md | 19 +- telemetry-worker/src/auth.ts | 17 +- telemetry-worker/src/billing-pages.ts | 21 +- telemetry-worker/src/billing.ts | 2 +- telemetry-worker/src/cli.ts | 50 +- telemetry-worker/src/plans.ts | 12 +- telemetry-worker/test/billing.test.ts | 12 +- telemetry-worker/test/cli-account.test.ts | 230 ++++++- telemetry-worker/test/cli.test.ts | 30 +- telemetry-worker/test/locked.test.ts | 3 + telemetry-worker/test/pages.test.ts | 2 +- telemetry-worker/test/plans.test.ts | 150 ++-- telemetry-worker/test/project-plans.test.ts | 721 ++++++++++++++------ telemetry-worker/test/team.test.ts | 4 + 31 files changed, 1589 insertions(+), 583 deletions(-) create mode 100644 packages/cli/src/paywall.mjs diff --git a/billing/plans.json b/billing/plans.json index 9a4c8819..59cbcaf0 100644 --- a/billing/plans.json +++ b/billing/plans.json @@ -1,5 +1,5 @@ { - "_about": "The one place GenClass plans are defined. telemetry-worker/src/plans.ts imports this file (limits, entitlements, Stripe lookup keys, the billing page) and genclass.dev's site reads it (the pricing page). Three plans since version 3 (2026-11-11): Free, Team and Enterprise; since version 4 (2026-11-12) a plan belongs to a project, not to an account. Free is what finds, shows and fixes the problems: the open-source runtime with every mode and its on-device model, the coding-agent loop on the developer's machine, the dashboard's problems for an account's first 3 projects (the last 7 days shown; a 4th project on Free is locked until it has a plan), and 100 problems fixed live a month per project (limits.live_fixes_per_month; past it the runtime is told to observe only until the month ends) and genclass-dev[bot] on public repositories. Team is sold per project (per_project true: one Stripe subscription of quantity 1 per project, its metadata naming the project; everyone on the account's team sees it; telemetry-worker/src/stripe.ts) and adds what a team uses: genclass-dev[bot] on private repositories, visitor analytics, forwarding, export, 90 days of history and members. Enterprise is agreed by email: a null monthly price means the plan is not sold on the site, and an account gets it from GenClass's own grant (accounts.comp_until, set by hand to the end of the agreement; accounts.comp_plan names the plan granted, grant_plan when it is empty). The classifier a person from GenClass trains is in Enterprise only. retired_plans lists the plan ids GenClass sold before (Pro and Custom until 2026-10-19, Individual until 2026-10-22): their Stripe products and prices stay GenClass's own, so a subscription to one is still cancelled with its account, opens Manage billing and is reported to the owner while it bills, but it gives no plan (it reads as Free). The worker's Stripe code and scripts/stripe-setup.mjs sell any plan given a monthly price above 0, its stripe_lookup_keys and a stripe_tax_code. Prices are US cents (per project for a per_project plan, per seat for a per_seat plan); a null yearly price means no yearly option. limits.apps null means no plan limit (the worker still caps an account at 200 apps against abuse); limits.seats is how many people a plan that is not per_seat holds (null: no limit); limits.history_days is what the dashboard shows, and data is never deleted before 90 days whatever the plan, so the longer history is there the moment an account upgrades. Ingest never depends on the plan: an app's visitor analytics are counted while its plan leaves them out, and shown once it includes them. Pro (id team; named Team until 2026-11-12) has a trial: trial_days (3) days free at Checkout, a card taken up front and charged when it ends unless cancelled, once per account (telemetry-worker/src/billing.ts trialFor). Feature lines with available false are shown as coming next. The entitlement github_private is what lets genclass-dev[bot], the GitHub App, review an account's private repositories (telemetry-worker/src/github/entitlement.ts); public repositories are reviewed for everyone, whatever this file says. A self-hosted collector sets PLAN_GATES = \"open\" and gives every account the grant plan. managed_payments true would sell a plan through Stripe Managed Payments (only for fully automated digital products; telemetry-worker/README.md, Paid plans). `suggestions` (2026-11-12): what `genclass suggest` gives a plan (telemetry-worker/src/suggest.ts): `per_day`, model runs an account has a day, and `shown`, how many items of the plan it is shown (null: all); the measured checks themselves are free for everyone. Entitlements `problems` (the problem lists, pages, activity and fix prompts on the dashboard and in the genclass command; Free shows only the totals) and `ask` (genclass ask) were added 2026-11-12 (the owner's 'limit free fully'); a plan file without the key gives them. Version 5 (2026-11-13, the owner's call): Pro is bought for the account again, not per app: one subscription, each unit of its quantity covers apps_per_unit (3) apps, oldest first, and a later app waits on its plan page (\"Add 3 more apps\"); a subscription bought per project under version 4 counts as one unit. Free covers one app. GenClass's grants cover every app.", + "_about": "The one place GenClass plans are defined. telemetry-worker/src/plans.ts imports this file (limits, entitlements, Stripe lookup keys, the billing page) and genclass.dev's site reads it (the pricing page). Three plans since version 3 (2026-11-11): Free, Team and Enterprise; since version 4 (2026-11-12) a plan belongs to a project, not to an account. Free is what finds, shows and fixes the problems: the open-source runtime with every mode and its on-device model, the coding-agent loop on the developer's machine, the dashboard's problems for an account's first 3 projects (the last 7 days shown; a 4th project on Free is locked until it has a plan), and 100 problems fixed live a month per project (limits.live_fixes_per_month; past it the runtime is told to observe only until the month ends) and genclass-dev[bot] on public repositories. Team is sold per project (per_project true: one Stripe subscription of quantity 1 per project, its metadata naming the project; everyone on the account's team sees it; telemetry-worker/src/stripe.ts) and adds what a team uses: genclass-dev[bot] on private repositories, visitor analytics, forwarding, export, 90 days of history and members. Enterprise is agreed by email: a null monthly price means the plan is not sold on the site, and an account gets it from GenClass's own grant (accounts.comp_until, set by hand to the end of the agreement; accounts.comp_plan names the plan granted, grant_plan when it is empty). The classifier a person from GenClass trains is in Enterprise only. retired_plans lists the plan ids GenClass sold before (Pro and Custom until 2026-10-19, Individual until 2026-10-22): their Stripe products and prices stay GenClass's own, so a subscription to one is still cancelled with its account, opens Manage billing and is reported to the owner while it bills, but it gives no plan (it reads as Free). The worker's Stripe code and scripts/stripe-setup.mjs sell any plan given a monthly price above 0, its stripe_lookup_keys and a stripe_tax_code. Prices are US cents (per project for a per_project plan, per seat for a per_seat plan); a null yearly price means no yearly option. limits.apps null means no plan limit (the worker still caps an account at 200 apps against abuse); limits.seats is how many people a plan that is not per_seat holds (null: no limit); limits.history_days is what the dashboard shows, and data is never deleted before 90 days whatever the plan, so the longer history is there the moment an account upgrades. Ingest never depends on the plan: an app's visitor analytics are counted while its plan leaves them out, and shown once it includes them. Pro (id team; named Team until 2026-11-12) has a trial: trial_days (3) days free at Checkout, a card taken up front and charged when it ends unless cancelled, once per account (telemetry-worker/src/billing.ts trialFor). Feature lines with available false are shown as coming next. The entitlement github_private is what lets genclass-dev[bot], the GitHub App, review an account's private repositories (telemetry-worker/src/github/entitlement.ts); public repositories are reviewed for everyone, whatever this file says. A self-hosted collector sets PLAN_GATES = \"open\" and gives every account the grant plan. managed_payments true would sell a plan through Stripe Managed Payments (only for fully automated digital products; telemetry-worker/README.md, Paid plans). `suggestions` (2026-11-12): what `genclass suggest` gives a plan (telemetry-worker/src/suggest.ts): `per_day`, model runs an account has a day, and `shown`, how many items of the plan it is shown (null: all); the measured checks themselves are free for everyone. Entitlements `problems` (the problem lists, pages, activity and fix prompts on the dashboard and in the genclass command; Free shows only the totals) and `ask` (genclass ask) were added 2026-11-12 (the owner's 'limit free fully'); a plan file without the key gives them. Version 5 (2026-11-13, the owner's call): Pro is bought for the account again, not per app, and per developer (per_seat: the subscription's quantity is how many developers, the owner included, US$15 each a month); it covers up to 3 apps (limits.apps) whatever the number of developers, the oldest first, and a later app waits on its plan page, where more apps are Enterprise's, by email. A subscription bought per project under version 4 still covers its app. Free covers one app. GenClass's grants cover every app. The genclass command is Pro's (2026-11-13, the owner's 'make the entire CLI paid'): a CLI session without Pro, its trial or a paying team gets 402 from every /api/cli route but the overview and the account; Free is the website's, with the owner's words for its list: one app, a limited dashboard, the open-source basics, and (the owner's 'keep them free') the GitHub App on public repositories, the page check and 100 live fixes a month.", "version": 5, "currency": "usd", "contact": "mehar@genclass.dev", @@ -10,7 +10,7 @@ { "id": "free", "name": "Free", - "summary": "The totals and one suggestion, for one app.", + "summary": "One app, its totals, and the open-source basics.", "price_usd_cents": { "monthly": 0, "yearly": null }, "stripe_lookup_keys": { "monthly": null, "yearly": null }, "stripe_tax_code": null, @@ -20,48 +20,47 @@ "suggestions": { "per_day": 3, "shown": 1 }, "entitlements": { "dashboard": true, "forwarding": false, "export": false, "analytics": false, "custom_model": false, "github_private": false, "problems": false, "ask": false }, "features": [ + { "text": "One app", "available": true }, + { "text": "Dashboard totals, last 7 days", "available": true }, { "text": "Open-source runtime, every mode", "available": true }, { "text": "On-device model", "available": true }, { "text": "Coding-agent loop on your machine (@genclass/mcp)", "available": true }, - { "text": "Dashboard totals, last 7 days", "available": true }, - { "text": "One app", "available": true }, { "text": "100 live fixes a month, then observe only", "available": true }, { "text": "GitHub App on public repositories", "available": true }, { "text": "Page check", "available": true }, - { "text": "Suggestions: your top one", "available": true }, { "text": "Self-host the collector", "available": true } ] }, { "id": "team", "name": "Pro", - "summary": "Everything for 3 apps, your whole team included.", + "summary": "Everything for up to 3 apps, per developer.", "price_usd_cents": { "monthly": 1500, "yearly": null }, "stripe_lookup_keys": { "monthly": "genclass_team_monthly", "yearly": null }, "stripe_tax_code": "txcd_10103001", "managed_payments": false, - "per_seat": false, + "per_seat": true, "trial_days": 3, - "limits": { "apps": null, "history_days": 90, "seats": null, "live_fixes_per_month": null }, + "limits": { "apps": 3, "history_days": 90, "seats": null, "live_fixes_per_month": null }, "suggestions": { "per_day": 30, "shown": null }, "entitlements": { "dashboard": true, "forwarding": true, "export": true, "analytics": true, "custom_model": false, "github_private": true, "problems": true, "ask": true }, "features": [ { "text": "Everything in Free", "available": true }, { "text": "3 days free to start", "available": true }, - { "text": "3 apps; 3 more for each extra $15 a month", "available": true }, + { "text": "Up to 3 apps", "available": true }, + { "text": "The genclass command in your terminal", "available": true }, { "text": "Every problem, its pages and a fix prompt", "available": true }, { "text": "Ask about your app in plain words", "available": true }, { "text": "Every suggestion, plus the model's own ideas", "available": true }, { "text": "Website design ideas from sites like yours", "available": true }, - { "text": "Unlimited live fixes", "available": true }, - { "text": "GitHub App on private repositories", "available": true }, { "text": "Visitor analytics", "available": true }, { "text": "Alerts to Sentry, PostHog, Datadog, Slack or a webhook", "available": true }, + { "text": "GitHub App on private repositories", "available": true }, + { "text": "Unlimited live fixes", "available": true }, { "text": "Data export and API", "available": true }, { "text": "90 days of history", "available": true }, - { "text": "Your whole team on it", "available": true } - ], - "apps_per_unit": 3 + { "text": "Your team on it, $15 a developer", "available": true } + ] }, { "id": "enterprise", @@ -77,6 +76,7 @@ "entitlements": { "dashboard": true, "forwarding": true, "export": true, "analytics": true, "custom_model": true, "github_private": true, "problems": true, "ask": true }, "features": [ { "text": "Everything in Pro", "available": true }, + { "text": "More than 3 apps", "available": true }, { "text": "Your own classifier, trained by us", "available": true }, { "text": "Our models in our cloud", "available": true }, { "text": "The coding-agent loop run for your team", "available": true }, diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 0ec6a335..a4f2543e 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -2,15 +2,24 @@ ## 0.3.7 -- **Every new app has its plan chosen right away.** `login`, `connect` and `init` (from the home prompt too) and - `apps new` open the app's plan page in the browser once it is in the account, and wait for the choice: Pro, with 3 - days free, or Free with a reason. Before, only the guided setup did, so an app connected another way stayed on Free - without anyone choosing. -- Opening an app with no plan chosen (`dashboard`, `problems`, ...) asks for its plan first, as its page on - genclass.dev does. -- The header shows this folder's app's plan, not the best plan of the account. `profile` and `plan` say "no plan - chosen" for an app that has none. -- The note under a dashboard link says what Free shows (the totals) and what comes with Pro. +The genclass command comes with Pro: + +- **Pro right after the sign-in.** An account without Pro (paid, in its trial, GenClass's grant, or a place in a + paying team) sees Pro's card next, and Enter opens its checkout in the browser: 3 days free, the card asked for on + Stripe, then $15 a developer a month. The command waits for the payment and goes on with what was asked. There is + no Free in the terminal: Free stays on the website (one app and its totals). +- **Every command asks first**, except `login`, `logout`, `whoami`, `plan`, `upgrade`, `profile`, `status`, `remove`, + `help` and `version`. Signed out, the sign-in comes first and Pro right after it. Without a terminal (`--yes`, + `--json`, a coding agent) nothing is opened: one line with the checkout address, and exit code 1. genclass.dev + answers 402 to the command's other routes for an account without Pro, and every command says the same line then. +- **Pro is for the account**, per developer, for up to 3 apps (genclass.dev's plans version 5). Free covers one + app. `plan`, `profile` and `upgrade` show Pro's card and its trial; an app past the 3 says more apps come with + Enterprise, by email. +- **Every new app has its plan page once.** `login`, `connect`, `init` and `apps new` open it once the app is in the + account: Continue with Pro for an account that has it, or what the app needs. The walk has it as its own step, + after the runtime. +- The header shows this folder's app's plan. The note under a dashboard link says what Free shows (the totals) and + what comes with Pro. ## 0.3.6 diff --git a/packages/cli/README.md b/packages/cli/README.md index 8acbd290..69a5a704 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -65,9 +65,15 @@ When the walk is done (or you press `q` to leave it for later), the command does ❯ which page loses the most visitors? ``` -Type a number, a command, or a sentence: a sentence is a question about your app. `q` leaves. On Free, each command -ends with one line on Free's limits and `genclass upgrade`. Everything here is also a -command of its own (`npx genclass problems`), so scripts and coding agents use the same things. +Type a number, a command, or a sentence: a sentence is a question about your app. `q` leaves. Everything here is +also a command of its own (`npx genclass problems`), so scripts and coding agents use the same things. + +**The command comes with Pro.** Right after the sign-in, an account without Pro sees Pro's card, and Enter opens its +checkout in the browser: 3 days free (the card is asked for on Stripe, nothing is charged if you cancel before they +end), then $15 a developer a month, for up to 3 apps. Every command but `login`, `logout`, `whoami`, `plan`, +`upgrade`, `profile`, `status`, `remove`, `help` and `version` asks first. Without a terminal (`--yes`, `--json`, a +coding agent) nothing is opened: one line with the checkout address, and exit code 1. Free stays on the website: one +app and its totals. **Just `genclass`.** `npx` runs the command without installing it, so the first run on a terminal installs it (`npm install -g genclass`, said on screen; `npm uninstall -g genclass` takes it out, `GENCLASS_NO_INSTALL=1` turns @@ -78,8 +84,9 @@ it off). After that you type `genclass`, and every screen says `genclass` instea | Part | What the step does | What it changes | |---|---|---| | Account | Opens genclass.dev in your browser; you approve a short code there. | A session file in your user config folder. Nothing is typed in the terminal. | +| Pro | Right after the sign-in: Pro's 3-day free trial opens in your browser, and the command waits for it. Every step after it needs Pro. | Nothing in your project. The payment is on Stripe's page; the command never sees a card. | | Runtime | Adds GenClass to the app: one import (or script tag) and the package. It watches the page in your users' browsers for lost edits, double submits and old data shown over new, and only reports, to the console and your dashboard. Nothing is drawn on your pages. | Your entry file and `package.json`; you see the change first. `genclass remove` undoes it. | -| Plan | Once the app is in your account, its plan page opens in your browser: Team, or Free for now (limited) after saying what stops you from paying. Each app has its own plan. The setup goes on once it has one. | Nothing in your project. The payment is on Stripe's page; the command never sees a card. | +| App plan | Once the app is in your account, its plan page opens in your browser once: Continue with Pro. Pro covers up to 3 apps; more apps come with Enterprise, by email. | Nothing in your project. | | Dashboard | Waits for your live site to be opened once. GenClass sends nothing from localhost. | Nothing. | | Test workflow | Runs your Playwright tests once more per pull request with slow and reordered responses. | One new file, `.github/workflows/genclass.yml`, which you commit. | | GitHub App | Comments on a pull request when a page ends up wrong, with the steps to see it. The command opens GitHub's install page and waits for the answer. A repository that was renamed on GitHub is looked up under its new name. | Nothing in your project. The app cannot read or change your code. | @@ -167,7 +174,7 @@ the question; a follow-up ("and on which page?") carries the turn before it. **What leaves your machine:** the question. The numbers are read on genclass.dev, where they already are, and go to the model (OpenAI's API) with it. No code, no file of your project. Neither the question nor the answer is stored. The command says this before the first question and waits for Enter. Ask and suggest share a plan's model runs a -day (3 on Free, 30 on Team). +day (30 on Pro). ## Connections @@ -227,8 +234,8 @@ evidence lines, plus your app's name, framework, address, payment provider and h contents, no secrets. The command shows this before the first time it sends it for a project; `--no-model` sends nothing. genclass.dev passes it to the model that writes the plan and stores none of it. -**What is free.** Every check, with its evidence. The plan (why it matters for your app, the first step, the agent -prompt) is shown for the first three items on Free and for all of them on Team, with more plans a day. +**What Pro shows.** Every check, with its evidence, and the plan for all of them: why it matters for your app, the +first step and the agent prompt, with the model's own ideas and website designs. ## Commands diff --git a/packages/cli/src/actions.mjs b/packages/cli/src/actions.mjs index cd33f375..b4745aa3 100644 --- a/packages/cli/src/actions.mjs +++ b/packages/cli/src/actions.mjs @@ -26,9 +26,8 @@ import { run as runLogin } from "../../runtime/bin/lib/login.mjs"; import { c, interactive, out, printChange, stopOnEnter, sym } from "../../runtime/bin/lib/ui.mjs"; import { fetchOverview, fetchRepoStatus } from "./api.mjs"; import { BUILD, SKILL_FILE, WORKFLOW_FILE } from "./assets.mjs"; -import { freeLimits, WORKFLOW_PATH } from "./state.mjs"; -import { saveNotes } from "./store.mjs"; -import { agree, bad, clean, good, note, planCard, PROMPT, spinner, typed, warn } from "./tui.mjs"; +import { accountPaid, appPlanOffer, WORKFLOW_PATH } from "./state.mjs"; +import { agree, bad, clean, good, note, planCard, spinner, warn } from "./tui.mjs"; import { planViteSettings } from "./vite.mjs"; /** Resolves after `ms`, or at once when `signal` aborts. */ @@ -100,39 +99,26 @@ export async function signIn(o = {}) { return "done"; } -// ------------------------------------------------------------------------------------------------ 1b. the plan +// ------------------------------------------------------------------------------------------------ 1b. Pro /** - * The plan step, for an account on Free that can buy a plan here: the paid plan's card, then one question. Enter - * opens that plan's checkout in the browser (genclass.dev sends the person straight to Stripe) and waits for the - * payment; f goes on with Free for this project, and that choice is remembered so the project is asked once. With - * --yes, or with nobody at a terminal, nothing is bought and nothing is opened: it is Free for now, said in one line - * with where the plan starts. Returns "done", "skipped" (asked again next run) or "quit" (q: stop the walk). + * The Pro step, right after the sign-in (state.mjs, step "plan"): the genclass command is Pro's (the owner's call, + * 2026-11-13), so an account without it sees the paywall (paywall.mjs startPro): the card, then Enter, which opens + * the checkout with its free trial and waits for the payment. There is no Free here. Returns "done" once Pro is on, + * "skipped" when there is nothing to start, and "quit" otherwise (the walk stops: nothing after it works without Pro). * @param {{ fetchFn?: typeof fetch, waitMs?: number, everyMs?: number }} [deps] */ export async function choosePlan(state, o = {}, deps = {}) { - const offer = state.steps.find((s) => s.id === "plan")?.offer; + const cli = state.steps.find((s) => s.id === "plan")?.cli; + if (!cli || cli.paid) return "skipped"; + return (await (await import("./paywall.mjs")).startPro(cli, state.account?.offer ?? null, o, deps)) ? "done" : "quit"; +} + +/** This app's plan page, in the walk (state.mjs, step "app-plan"): projectPlan with the step's offer. */ +export async function chooseAppPlan(state, o = {}, deps = {}) { + const offer = state.steps.find((s) => s.id === "app-plan")?.offer; if (!offer) return "skipped"; - // plans per project: the choice is made on the app's own plan page - if (offer.project) return projectPlan(offer, o, deps); - const free = offer.free.name; - const limits = freeLimits(offer.free); - const stayFree = (line) => { - saveNotes(state.cwd, { plan: "free", planAt: new Date().toISOString() }); - note(line); - return "done"; - }; - if (o.yes || !interactive()) return stayFree(`${free} for now (limited${limits ? `: ${limits}` : ""}). ${offer.plan.name}, ${offer.price}: ${offer.checkoutUrl}`); - planCard(offer, state.app?.name ?? null); - // the small way on, in the owner's words - out(` ${c.gray(`f Try ${free.toLowerCase()} for now (limited)`)}`); - const dot = c.gray("·"); - const a = await typed(` ${PROMPT()} ${c.bold("Enter")} ${c.gray(`to start ${offer.plan.name}`)} ${dot} ${c.bold("f")} ${c.gray("free for now")} ${dot} ${c.bold("q")} ${c.gray("stop")} `); - if (a === null || /^q/i.test(a)) return "quit"; - if (/^[fs]/i.test(a)) return stayFree(`${free} for now. ${offer.plan.name} any time: ${offer.checkoutUrl}`); - if (await checkout(offer, o, deps)) return "done"; - note(`This project goes on with ${free} for now.`); - return "skipped"; + return projectPlan(offer, o, deps); } /** @@ -169,12 +155,9 @@ export async function planNow(o = {}, deps = {}, id = null) { } const app = (state.projects ?? []).find((p) => (id ? p.id === id : p.token === state.app?.token) && !p.shared); // version 5 (2026-11-13): every new app sees its plan page, a paying account's too (it goes on with its plan - // there), and an app its plan does not cover waits there for more - if (!app?.plan || !app.planUrl || (app.planChosen && !app.needsPlan)) return "none"; - const covered = !!app.plan.source && !app.needsPlan; - const base = upgradeOffer(state, app.id) ?? state.account?.offer ?? { plan: { name: "Pro" }, price: "", summary: null, features: [], free: { name: "Free", apps: null } }; - if (!covered && !state.account?.offer && !app.plan.source) return "none"; - const offer = { ...base, checkoutUrl: `${app.planUrl}&from=cli`, project: { id: app.id, name: app.name, token: app.token }, needs: app.needsPlan === true, current: covered ? app.plan.name : null, paying: !!app.plan.source }; + // there), and an app its plan does not cover waits there (state.mjs appPlanOffer) + const offer = appPlanOffer(app, state.account?.offer ?? null, !!state.account && accountPaid(state.account)); + if (!offer) return "none"; if (o.json) return "open"; if (o.yes || !interactive()) { out(); @@ -211,7 +194,9 @@ export async function projectPlan(offer, o = {}, deps = {}) { offer.current ? `Your ${clean(offer.current, 20)} plan covers ${name}: continue with it on its plan page.` : offer.needs && offer.paying - ? `Your plan covers no more apps: add ${offer.appsPerUnit ?? 3} more for ${name} on its plan page.` + ? offer.appsPerUnit + ? `Your plan covers no more apps: add ${offer.appsPerUnit} more for ${name} on its plan page.` + : `Your plan covers no more apps, and ${name} is one more. More apps come with Enterprise, by email: its plan page has the address.` : offer.needs ? `Free covers ${offer.free.apps === 1 ? "one app" : `${offer.free.apps ?? 1} apps`} and this is one more: it needs ${offer.plan.name}, ${offer.price}.` : `${offer.plan.name} is ${offer.price}. Or try ${offer.free.name.toLowerCase()} for now (limited), on the same page.`, @@ -255,7 +240,7 @@ async function waitForPlan(offer, o = {}, deps = {}) { if (offer.current) got = p?.planChosen ? "kept" : null; else if (p?.plan && (p.plan.id !== "free" || p.plan.source) && !p.needsPlan) got = "paid"; else if (p?.planChosen && !offer.needs && !p.needsPlan) got = "free"; - } else if (me.plan && me.plan.id !== "free") got = "paid"; + } else if (me.cli ? me.cli.paid : me.plan && me.plan.id !== "free") got = "paid"; if (got) break; } await stop.done(); diff --git a/packages/cli/src/api.mjs b/packages/cli/src/api.mjs index b6545043..b2ebadf6 100644 --- a/packages/cli/src/api.mjs +++ b/packages/cli/src/api.mjs @@ -12,6 +12,10 @@ import { cleanProject, failure, request } from "../../runtime/bin/lib/account.mjs"; const str = (v, max = 200) => (typeof v === "string" ? v.slice(0, max) : null); +/** The server's 402 for an account without Pro (the genclass command is Pro's since 2026-11-13), as cliOf reads it. */ +const proOf = (r, host) => (r.status === 402 && r.json?.needs_pro === true ? { needsPro: cliOf({ ...r.json, paid: false }, host) } : {}); +/** Why a call failed, in the server's own sentence when Pro is why. */ +const why = (r, what) => (r.status === 402 && r.json?.needs_pro === true && typeof r.json.error === "string" ? r.json.error.slice(0, 300) : failure(r, what)); const sameOrigin = (u, host) => { try { return typeof u === "string" && new URL(u).origin === host ? u : null; @@ -46,6 +50,27 @@ export async function fetchOverview(creds, opts = {}) { github: j.github && typeof j.github === "object" ? { app: j.github.app === true, installUrl: sameOrigin(j.github.install_url, creds.host), connectUrl: sameOrigin(j.github.connect_url, creds.host) } : null, billingUrl: sameOrigin(j.billing_url, creds.host), offer: offerOf(j.offer, creds.host), + cli: cliOf(j.cli, creds.host), + }; +} + +/** + * Whether this command works for the account (it is Pro's since 2026-11-13: Pro, its trial, GenClass's grant or a + * place in a paying team) and, without it, where Pro starts: the plan's name, its price, the trial the account has + * left, and the page that opens the checkout (on the server's own host; its billing page when it sent another). + * Null when the server does not say (an older or self-hosted one): nothing is locked then. + * @returns {{ paid: true } | { paid: false, plan: string, price: string | null, trialDays: number | null, checkoutUrl: string } | null} + */ +export function cliOf(v, host) { + if (!v || typeof v !== "object" || typeof v.paid !== "boolean") return null; + if (v.paid) return { paid: true }; + const days = Number(v.trial_days); + return { + paid: false, + plan: str(v.plan, 40) ?? "Pro", + price: str(v.price, 60), + trialDays: Number.isInteger(days) && days > 0 && days <= 730 ? days : null, + checkoutUrl: sameOrigin(v.checkout_url, host) ?? `${host}/account/billing`, }; } @@ -92,7 +117,7 @@ function offerOf(o, host) { */ export async function fetchRepoStatus(creds, repo, opts = {}) { const r = await request(`/api/cli/github?repo=${encodeURIComponent(repo)}`, { token: creds.access_token, host: creds.host, fetchFn: opts.fetchFn }); - if (!r.ok) return { ok: false, status: r.status, reason: failure(r), ...(r.status === 404 ? { missing: true } : {}) }; + if (!r.ok) return { ok: false, status: r.status, reason: why(r), ...(r.status === 404 ? { missing: true } : {}), ...proOf(r, creds.host) }; const j = r.json ?? {}; return { ok: true, @@ -118,7 +143,7 @@ export async function fetchRepoStatus(creds, repo, opts = {}) { export async function requestPlan(creds, digest, opts = {}) { // the model works through the checks, its own ideas and three website designs: up to 90 seconds on the server const r = await request("/api/cli/suggest", { method: "POST", token: creds.access_token, host: creds.host, body: digest, fetchFn: opts.fetchFn, timeoutMs: opts.timeoutMs ?? 120_000 }); - if (!r.ok) return { ok: false, status: r.status, reason: failure(r), ...(r.status === 404 ? { missing: true } : {}) }; + if (!r.ok) return { ok: false, status: r.status, reason: why(r), ...(r.status === 404 ? { missing: true } : {}), ...proOf(r, creds.host) }; const j = r.json ?? {}; const items = (Array.isArray(j.items) ? j.items : []) .filter((i) => i && typeof i === "object" && /^[a-z0-9-]{1,40}$/.test(String(i.id ?? ""))) @@ -198,7 +223,7 @@ function liveUsage(u) { */ export async function fetchApp(creds, id, range = "7d", opts = {}) { const r = await request(`/api/cli/app?id=${encodeURIComponent(id)}&range=${encodeURIComponent(range)}`, { token: creds.access_token, host: creds.host, fetchFn: opts.fetchFn }); - if (!r.ok) return { ok: false, status: r.status, reason: failure(r), ...(r.status === 404 ? { missing: true } : {}), ...(r.json?.reauth === true ? { reauth: true } : {}), ...(r.json?.locked === true ? { locked: true, upgradeUrl: sameOrigin(r.json.upgrade_url, creds.host) } : {}) }; + if (!r.ok) return { ok: false, status: r.status, reason: why(r), ...(r.status === 404 ? { missing: true } : {}), ...proOf(r, creds.host), ...(r.json?.reauth === true ? { reauth: true } : {}), ...(r.json?.locked === true ? { locked: true, upgradeUrl: sameOrigin(r.json.upgrade_url, creds.host) } : {}) }; const j = r.json ?? {}; const t = j.totals && typeof j.totals === "object" ? j.totals : {}; const b = j.breakdowns && typeof j.breakdowns === "object" ? j.breakdowns : {}; @@ -310,6 +335,8 @@ const refused = (r, host, what) => ({ ...(sameOrigin(r.json?.upgrade_url, host) ? { upgradeUrl: sameOrigin(r.json.upgrade_url, host) } : {}), // a part of the paid plan: which plan, its trial and price (the Pro box) ...(r.json?.locked === true ? { lock: lockOf(r.json, host) } : {}), + // the genclass command is Pro's: where Pro starts + ...proOf(r, host), }); const numOrNull = (v) => (Number.isFinite(Number(v)) && v !== null ? Number(v) : null); const person = (x) => ({ label: str(x?.email, 120) ?? (str(x?.github_login, 60) ? `@${str(x.github_login, 60)}` : "a GenClass account"), joined: str(x?.joined, 40), active: x?.active !== false }); diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index ed310baa..132fa535 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -8,7 +8,10 @@ // file hands them their arguments. `remove` is the runtime CLI's, followed by what this command's other steps wrote // (remove.mjs). `dashboard` is this command's own view (app.mjs); signed out it falls back to the runtime CLI's, // which knows an app's private dashboard link. -// - Nothing here reads a file or the network: it parses and dispatches. +// - The command is Pro's (the owner's call, 2026-11-13): every command but FREE_COMMANDS passes paywall.mjs +// requirePro first (signed out: the sign-in, then Pro with its free trial). The walk (`genclass`, `setup`) has Pro +// as its own step right after the sign-in (state.mjs), and a command run from the home (ctx.nested) came after it. +// - Nothing here reads a file or the network but that gate: it parses and dispatches. import { hostConfig } from "../../runtime/bin/lib/host.mjs"; import { c, err, interactive, out } from "../../runtime/bin/lib/ui.mjs"; @@ -87,6 +90,15 @@ export async function run(argv, ctx = {}) { const cmd = argv[0] && !argv[0].startsWith("-") ? argv[0] : null; const args = cmd ? argv.slice(1) : argv; + // the genclass command is Pro's: a command asks genclass.dev first (paywall.mjs requirePro), once its words are + // known to be right. Not: signing in and out, the plan, help, the version, status (where things stand, Pro's step + // included), remove, a command run from the home, and the walk, whose own Pro step comes right after the sign-in + const asksHelp = args.some((a) => ["--help", "-h", "--version", "-v"].includes(a)); + const paid = async () => { + if (ctx.nested || asksHelp || FREE_COMMANDS.includes(cmd) || hostConfig().error) return 0; + return (await import("./paywall.mjs")).requirePro(parseArgs(args).o); + }; + // the runtime CLI's own commands, with their own flags and help if (cmd === "login" && !args.some((a) => a === "--help" || a === "-h")) { // a sign-in from before the command could show dashboards: "already signed in" would be a dead end for @@ -94,16 +106,24 @@ export async function run(argv, ctx = {}) { const again = await (await import("./app.mjs")).signInAgain(parseArgs(args).o); if (again !== null) return again; } - // an app that login, connect or init put in the account opens its plan page next (actions.mjs planNow) + // Pro comes right after the sign-in (paywall.mjs afterLogin), and an app that login, connect or init put in the + // account opens its plan page next (actions.mjs planNow) const thenPlan = async (code) => { if (code !== 0 || ctx.inWalk) return code; const i = args.indexOf("--cwd"); - await (await import("./actions.mjs")).planNow({ cwd: i >= 0 ? args[i + 1] : undefined, yes: args.includes("--yes"), json: args.includes("--json") }).catch(() => "none"); + const po = { cwd: i >= 0 ? args[i + 1] : undefined, yes: args.includes("--yes"), json: args.includes("--json"), noBrowser: args.includes("--no-browser") }; + if (cmd === "login" && !(await (await import("./paywall.mjs")).afterLogin(po).catch(() => true))) return code; + await (await import("./actions.mjs")).planNow(po).catch(() => "none"); return code; }; if (["logout", "whoami"].includes(cmd)) return (await import("../../runtime/bin/lib/login.mjs")).run(cmd, args); - if (["login", "connect"].includes(cmd)) return thenPlan(await (await import("../../runtime/bin/lib/login.mjs")).run(cmd, args)); + if (cmd === "login") return thenPlan(await (await import("../../runtime/bin/lib/login.mjs")).run(cmd, args)); if (cmd === "remove") return (await import("./remove.mjs")).remove(args); + if (["connect", "init", "scan", "audit"].includes(cmd)) { + const gate = await paid(); + if (gate !== 0) return gate; + } + if (cmd === "connect") return thenPlan(await (await import("../../runtime/bin/lib/login.mjs")).run(cmd, args)); if (cmd === "init") return thenPlan(await (await import("../../runtime/bin/lib/init.mjs")).run(cmd, args)); if (cmd === "scan" || cmd === "audit") { // this command carries no copy of the runtime's script-tag build, so the page check runs on genclass.dev unless asked otherwise @@ -149,6 +169,8 @@ export async function run(argv, ctx = {}) { err(`genclass suggest: unknown option ${unknown[0]}\n\n${USAGE}`); return 2; } + const gate = await paid(); + if (gate !== 0) return gate; return upsold(runSuggest({ ...o, url })); } if (cmd !== null && !COMMANDS.includes(cmd)) { @@ -165,26 +187,29 @@ export async function run(argv, ctx = {}) { err(`genclass: unknown ${bad.startsWith("-") ? "option" : "command"} ${c.bold(bad)}\n\n${USAGE}`); return 2; } + // the words of the commands that take them, before anything is asked of genclass.dev + const { ANALYTICS_SECTIONS } = cmd === "analytics" ? await import("./views.mjs") : { ANALYTICS_SECTIONS: [] }; + if (cmd === "analytics" && words[0] && !ANALYTICS_SECTIONS.includes(words[0])) { + err(`genclass analytics: there is no part ${c.bold(words[0])}. The parts: ${ANALYTICS_SECTIONS.join(", ")}`); + return 2; + } + if (cmd === "agent" && words[0] && words[0] !== "link") { + err(`genclass agent: there is no ${c.bold(words[0])}. Try ${c.cyan("npx genclass agent")} or ${c.cyan("npx genclass agent link")}`); + return 2; + } + if (cmd !== null && !["setup", "status"].includes(cmd)) { + const gate = await paid(); + if (gate !== 0) return gate; + } if (cmd === "dashboard") { // signed out, the runtime CLI's own command still knows an app's private dashboard link const fallback = async () => (await import("../../runtime/bin/lib/login.mjs")).run("dashboard", [...(o.cwd ? ["--cwd", o.cwd] : []), ...(o.open ? ["--open"] : [])]); return upsold((await import("./app.mjs")).dashboard(o, { fallback })); } if (["problems", "events", "health", "report"].includes(cmd)) return upsold((await import("./views.mjs"))[cmd](o), cmd !== "report"); - if (cmd === "analytics") { - const { analytics, ANALYTICS_SECTIONS } = await import("./views.mjs"); - if (words[0] && !ANALYTICS_SECTIONS.includes(words[0])) { - err(`genclass analytics: there is no part ${c.bold(words[0])}. The parts: ${ANALYTICS_SECTIONS.join(", ")}`); - return 2; - } - return upsold(analytics({ ...o, section: words[0] ?? null })); - } + if (cmd === "analytics") return upsold((await import("./views.mjs")).analytics({ ...o, section: words[0] ?? null })); if (cmd === "agent") { if (words[0] === "link") return (await import("./manage.mjs")).agentLink(o); - if (words[0]) { - err(`genclass agent: there is no ${c.bold(words[0])}. Try ${c.cyan("npx genclass agent")} or ${c.cyan("npx genclass agent link")}`); - return 2; - } return (await import("./views.mjs")).agent(o); } if (cmd === "fix") return upsold((await import("./fix.mjs")).fix({ ...o, what: words.join(" ") || null })); @@ -203,6 +228,9 @@ export async function run(argv, ctx = {}) { return 2; } +/** The commands that work without Pro: signing in and out, the plan itself, where things stand, help, the version, and taking GenClass out. */ +export const FREE_COMMANDS = ["login", "logout", "whoami", "plan", "upgrade", "profile", "status", "remove", "help", "version"]; + /** Every command, for the "did you mean" of a mistyped one. */ export const COMMANDS = [ "setup", "status", "suggest", "dashboard", "problems", "analytics", "events", "health", "report", "export", "fix", "ask", "scan", "connections", "agent", "funnels", diff --git a/packages/cli/src/manage.mjs b/packages/cli/src/manage.mjs index 0c10b6b9..36cddd70 100644 --- a/packages/cli/src/manage.mjs +++ b/packages/cli/src/manage.mjs @@ -21,7 +21,7 @@ import { changeConnection, createInvite, fetchAccount, fetchApp, fetchOverview, import { appArg, n, pickApp } from "./app.mjs"; import { copyText, onPath } from "./fix.mjs"; import { asking, refused } from "./session.mjs"; -import { planName, readState } from "./state.mjs"; +import { accountPaid, planName, readState } from "./state.mjs"; import { ago, agree, bad, clean, good, header, note, planCard, PROMPT, secret, spinner, typed, warn, width, wrap } from "./tui.mjs"; import { agentSetup } from "./views.mjs"; @@ -83,7 +83,7 @@ export async function apps(o = {}, deps = {}) { const me = await fetchOverview(creds, deps).catch(() => null); const made = me?.ok ? me.projects.find((p) => p.token === r.token) : null; if (made?.needsPlan && made.planUrl) { - warn(made.plan?.source ? "Your plan covers no more apps: this one needs more on it." : "Free covers one app: this one needs a paid plan."); + warn(accountPaid(me) ? "Your plan covers no more apps: more come with Enterprise, by email (its plan page has the address)." : "Free covers one app: this one needs Pro."); out(` ${c.gray("Its plan:")} ${c.cyan(made.planUrl)}`); } else if (made?.plan?.id === "free" && made.planUrl) out(` ${c.gray("On Free (limited). Its plan:")} ${c.cyan(made.planUrl)}`); // its plan is chosen now, on its page (Pro, or Free with a reason) @@ -135,12 +135,17 @@ export async function plan(o = {}, deps = {}) { const a = await asking(o, creds, (cr) => fetchAccount(cr, deps), deps); // plans per project: this folder's app's plan (the setup's plan step), else, from a server from before them, the // account's; an older sign-in or an older server: what the overview knows is still an answer - const step = state.steps.find((x) => x.id === "plan"); + // the genclass command is Pro's: an account without it is shown where Pro starts (paywall.mjs), else this folder's + // app's plan page when it has one to see, else (a server from before plans per project) the account's offer + const cli = state.account?.cli ?? null; + const pro = cli && !cli.paid ? cli : null; + const step = state.steps.find((x) => x.id === "app-plan"); const mine = (state.projects ?? []).filter((p) => p.plan); - const offer = step?.offer ?? (mine.length ? null : a.ok ? a.offer : null); + const offer = pro ? null : (step?.offer ?? (mine.length ? null : a.ok ? a.offer : null)); if (o.json) { const apps = mine.map((p) => ({ id: p.id, name: p.name, plan: p.plan, needs_plan: p.needsPlan, plan_url: p.planUrl, this_folder: p.token === state.app?.token })); - out(JSON.stringify(a.ok ? { ok: true, plan: a.plan, access: a.access, limits: a.limits, used: a.used, includes: a.includes, subscription: a.subscription, billing_url: a.billingUrl, offer, apps } : { ok: true, plan: state.account?.plan ?? null, billing_url: state.account?.billingUrl ?? null, offer, partial: a.reauth ? "sign in again for the limits: npx genclass login" : "this genclass.dev says no more" }, null, 2)); + const command = cli ? { paid: cli.paid, ...(pro ? { checkout_url: pro.checkoutUrl, trial_days: pro.trialDays, price: pro.price } : {}) } : null; + out(JSON.stringify(a.ok ? { ok: true, plan: a.plan, access: a.access, limits: a.limits, used: a.used, includes: a.includes, subscription: a.subscription, billing_url: a.billingUrl, offer: offer ?? (pro ? a.offer : null), cli: command, apps } : { ok: true, plan: state.account?.plan ?? null, billing_url: state.account?.billingUrl ?? null, offer, cli: command, partial: a.reauth ? "sign in again for the limits: npx genclass login" : "this genclass.dev says no more" }, null, 2)); return 0; } top(state); @@ -171,6 +176,11 @@ export async function plan(o = {}, deps = {}) { out(` ${clean(p.name, 24).padEnd(24)} ${what}${p.token === state.app?.token ? c.gray(" this folder") : ""}`); } } + if (pro) { + await (await import("./paywall.mjs")).startPro(pro, state.account?.offer ?? (a.ok ? a.offer : null), { ...o, card: true }, deps); + out(); + return 0; + } if (!offer) { out(); out(` ${c.gray("Billing, in the browser")} ${c.cyan(billingUrl)}`); @@ -218,10 +228,20 @@ export async function freeLine(o = {}, deps = {}) { return false; } if (!state.signedIn) return false; - const offer = upgradeOffer(state); - if (!offer) return false; - const lim = freeWords(offer.free); - const [first = "", ...rest] = wrap(`You are on Free, which has limits${lim ? ` (${lim})` : ""}. ${clean(offer.plan.name, 20)} has everything, ${offer.trialDays ? `${offer.trialDays} days free, then ${clean(offer.price, 40)}` : `for ${clean(offer.price, 40)}`}. Upgrade now: npx genclass upgrade`, width() - 6); + const cli = state.account?.cli ?? null; + const offer = cli?.paid ? null : (state.account?.offer ?? upgradeOffer(state)); + if (!offer && !(cli && !cli.paid)) return false; + const lim = offer ? freeWords(offer.free) : ""; + const name = clean(cli && !cli.paid ? cli.plan : offer.plan.name, 20); + const price = clean((cli && !cli.paid ? cli.price : null) ?? offer?.price ?? "", 40); + const days = cli && !cli.paid ? cli.trialDays : offer?.trialDays; + // the genclass command is Pro's: Free is the website's (one app and its totals), and the line says so + const [first = "", ...rest] = wrap( + cli && !cli.paid + ? `You are on Free${lim ? ` (${lim})` : ""}, on the website. Every genclass command comes with ${name}, ${days ? `${days} days free, then ${price}` : price}. Start it: npx genclass upgrade` + : `You are on Free, which has limits${lim ? ` (${lim})` : ""}. ${name} has everything, ${days ? `${days} days free, then ${price}` : `for ${price}`}. Upgrade now: npx genclass upgrade`, + width() - 6, + ); out(); out(` ${c.yellow(sym.warn)} ${c.bold(first)}`); for (const l of rest) out(` ${l}`); @@ -236,6 +256,18 @@ export async function upgrade(o = {}, deps = {}) { const s = await signedIn(o, deps, "upgrade"); if ("code" in s) return s.code; const { state } = s; + // an account without Pro starts it here, for the whole account: the paywall the other commands show (paywall.mjs) + const cli = state.account?.cli ?? null; + if (cli && !cli.paid) { + if (o.json) { + out(JSON.stringify({ ok: true, app: null, plan: { name: cli.plan }, price: cli.price, trial_days: cli.trialDays, url: cli.checkoutUrl })); + return 0; + } + top(state); + await (await import("./paywall.mjs")).startPro(cli, state.account?.offer ?? null, { ...o, card: true }, deps); + out(); + return 0; + } const offer = upgradeOffer(state, o.app ?? null); const billingUrl = state.account?.billingUrl ?? hostUrl("/account/billing"); if (o.json) { @@ -270,17 +302,19 @@ export async function profile(o = {}, deps = {}) { if ("code" in s) return s.code; const { state } = s; const mine = (state.projects ?? []).filter((p) => p.plan && !p.shared); - const offer = upgradeOffer(state, o.app ?? null); + const cli = state.account?.cli ?? null; + const pro = cli && !cli.paid ? cli : null; + const offer = pro ? null : upgradeOffer(state, o.app ?? null); const billingUrl = state.account?.billingUrl ?? hostUrl("/account/billing"); const onFree = state.account?.plan?.id === "free" || (mine.length > 0 && mine.every((p) => p.plan.id === "free")); if (o.json) { - out(JSON.stringify({ ok: true, account: state.account?.label ?? null, plan: state.account?.plan ?? null, apps: mine.map((p) => ({ id: p.id, name: p.name, plan: p.plan, needs_plan: p.needsPlan })), upgrade_url: offer?.checkoutUrl ?? null, billing_url: billingUrl })); + out(JSON.stringify({ ok: true, account: state.account?.label ?? null, plan: state.account?.plan ?? null, apps: mine.map((p) => ({ id: p.id, name: p.name, plan: p.plan, needs_plan: p.needsPlan })), upgrade_url: pro?.checkoutUrl ?? offer?.checkoutUrl ?? null, cli: cli ? { paid: cli.paid } : null, billing_url: billingUrl })); return 0; } top(state); out(` ${c.bold("Profile")}`); row("Signed in as", clean(state.account?.label ?? "", 60)); - row("Plan", onFree ? `Free ${c.gray("(limited)")}` : c.bold(clean(state.account?.plan?.name ?? "", 20))); + row("Plan", onFree ? `Free ${c.gray(pro ? "(website only: the genclass command comes with Pro)" : "(limited)")}` : c.bold(clean(state.account?.plan?.name ?? "", 20))); if (mine.length) { out(); out(` ${c.bold("Apps and their plans")}`); @@ -290,6 +324,7 @@ export async function profile(o = {}, deps = {}) { } } const choices = []; + if (pro) choices.push([`${pro.trialDays ? `Start the ${pro.trialDays}-day free trial` : `Start ${clean(pro.plan, 20)}`} ${c.gray(clean(pro.price ?? "", 40))}`, "npx genclass upgrade", () => upgrade(o, deps)]); if (offer) choices.push([`Upgrade ${offer.project ? clean(offer.project.name, 20) : "to"} ${offer.project ? `to ${clean(offer.plan.name, 20)}` : clean(offer.plan.name, 20)} ${c.gray(clean(offer.price, 40))}`, "npx genclass upgrade", () => upgrade({ ...o, app: offer.project?.id ?? o.app }, deps)]); choices.push(["Billing, in the browser", billingUrl, () => (openBrowser(billingUrl, o) ? note("Your browser is opening billing.") : out(` ${c.cyan(billingUrl)}`))]); choices.push(["Sign out", "npx genclass logout", async () => (await import("../../runtime/bin/lib/login.mjs")).run("logout", [])]); @@ -476,6 +511,11 @@ export async function connections(o = {}, deps = {}) { const here = state.app.token === target.token; const agentHere = here ? agentSetup(state.cwd, deps.home) : null; const gh = here ? state.steps.find((x) => x.id === "github-app") : null; + // the genclass command is Pro's: an account without it is told where Pro starts, and nothing else + if (!r.ok && r.needsPro) { + if (!o.json) top(state, target); + return refused(r, { what: "Alerts", command: "connections", json: o.json }); + } if (o.json) { out(JSON.stringify({ ok: true, app: target.name, alerts: r.ok ? { in_plan: r.forwarding, destinations: r.list } : { error: r.reauth ? "sign in again" : r.reason }, private_link: r.ok ? r.privateLink : null, github_app: gh ? { state: gh.state, detail: gh.detail ?? null } : null, coding_agent: agentHere }, null, 2)); return 0; diff --git a/packages/cli/src/paywall.mjs b/packages/cli/src/paywall.mjs new file mode 100644 index 00000000..3b63e0fb --- /dev/null +++ b/packages/cli/src/paywall.mjs @@ -0,0 +1,156 @@ +// The genclass command is Pro's (the owner's call, 2026-11-13: "let's make the entire CLI paid"). Every command but +// the sign-in's, the plan's, help and remove asks genclass.dev first whether the account has Pro (paid, in its +// trial, GenClass's grant, or a place in a paying team) and, without it, shows the one way on: Pro, its free trial +// first (the card is asked for up front, on Stripe), started in the browser. There is no Free in the terminal: the +// website keeps Free. +// +// Invariants: +// - Signed out, the sign-in comes first and the plan right after it: the first thing a new person does is sign in +// and start the trial. +// - The answer is genclass.dev's (`cli` in GET /api/cli/overview, api.mjs cliOf). A server that does not say (an +// older or self-hosted one) locks nothing, and neither does one that cannot be reached: its own routes refuse +// what is Pro's (402) when it is back. +// - The command never says to go back to the terminal: it opens the checkout, waits for the payment itself and goes +// on with the command that was asked for. +// - Without a terminal (--yes, --json, a coding agent) nothing is opened and nothing waits: one line with the +// checkout address, and exit code 1. +// - Asked once per process: once Pro is seen, the home's commands (home.mjs) go on without asking again. + +import { readCredentials } from "../../runtime/bin/lib/account.mjs"; +import { c, interactive, out } from "../../runtime/bin/lib/ui.mjs"; +import { fetchOverview } from "./api.mjs"; +import { clean, note, planCard, PROMPT, typed, warn } from "./tui.mjs"; + +let seen = false; + +/** For tests: forget that Pro was seen in this process. */ +export const forgetPro = () => { + seen = false; +}; + +/** + * What the paywall shows for an account without Pro: the server's plan card (its offer, in genclass.dev's words) + * with the trial and checkout of `cli`. + * @param {{ plan: string, price: string | null, trialDays: number | null, checkoutUrl: string }} cli + * @param {object | null} offer the overview's `offer` (api.mjs offerOf), for the summary and the lines of the card + */ +export function proOffer(cli, offer) { + return { + plan: { name: cli.plan }, + price: cli.price ?? offer?.price ?? "", + summary: offer?.summary ?? null, + features: offer?.features ?? [], + trialDays: cli.trialDays, + checkoutUrl: cli.checkoutUrl, + }; +} + +/** The one line the command says without a terminal, and after a stop: what it needs and where it starts. */ +export function proLine(cli) { + const start = cli.trialDays ? `start your ${cli.trialDays}-day free trial` : `start ${clean(cli.plan, 20)}`; + return `The genclass command comes with ${clean(cli.plan, 20)}${cli.price ? ` (${clean(cli.price, 40)})` : ""}: ${start} at ${cli.checkoutUrl}`; +} + +/** + * The paywall, on a terminal: the card, then one question. Enter opens the checkout (genclass.dev sends the person + * straight to Stripe, the trial first) and waits for the payment; q stops. Without a terminal it prints proLine and + * opens nothing. Returns whether the account has Pro now. + * @param {{ plan: string, price: string | null, trialDays: number | null, checkoutUrl: string }} cli + * @param {object | null} offer + * @param {{ yes?: boolean, json?: boolean, noBrowser?: boolean, card?: boolean }} o `card`: the card without a terminal too + * @param {{ fetchFn?: typeof fetch, waitMs?: number, everyMs?: number }} [deps] + */ +export async function startPro(cli, offer, o = {}, deps = {}) { + const card = proOffer(cli, offer); + const heading = cli.trialDays ? `Start your ${cli.trialDays}-day free trial` : `genclass comes with ${clean(cli.plan, 20)}`; + if (o.yes || o.json || !interactive()) { + if (o.json) out(JSON.stringify({ ok: false, needs_pro: true, plan: cli.plan, price: cli.price, trial_days: cli.trialDays, checkout_url: cli.checkoutUrl })); + else { + // `genclass plan` and `upgrade` show the card all the same: it is what they are for + if (o.card && card.features.length) planCard(card, null, heading); + out(); + warn(proLine(cli)); + } + return false; + } + planCard(card, null, heading); + note( + cli.trialDays + ? `Every genclass command comes with ${clean(cli.plan, 20)}. ${cli.trialDays} days free, then ${clean(card.price, 40)}: your card is asked for on Stripe, and nothing is charged if you cancel before the trial ends.` + : `Every genclass command comes with ${clean(cli.plan, 20)}, ${clean(card.price, 40)}, paid on Stripe.`, + ); + out(); + const dot = c.gray("·"); + const a = await typed(` ${PROMPT()} ${c.bold("Enter")} ${c.gray(cli.trialDays ? "to start the free trial" : `to start ${clean(cli.plan, 20)}`)} ${dot} ${c.bold("q")} ${c.gray("stop")} `); + if (a === null || /^q/i.test(a)) { + out(); + note(proLine(cli)); + return false; + } + const { checkout } = await import("./actions.mjs"); + if (await checkout({ ...card, pro: true }, o, deps)) return true; + note(`${clean(cli.plan, 20)} starts the moment the payment goes through; npx genclass goes on from there.`); + return false; +} + +/** + * The gate every command but cli.mjs FREE_COMMANDS passes first. Signed out: the sign-in (on a terminal), then the plan. + * Returns 0 when the command may run, else the exit code to end with (1). + * @param {{ yes?: boolean, json?: boolean, noBrowser?: boolean }} o + * @param {{ fetchFn?: typeof fetch, signIn?: (o: object) => Promise, waitMs?: number, everyMs?: number }} [deps] + */ +export async function requirePro(o = {}, deps = {}) { + if (seen) return 0; + let creds = readCredentials(); + if (!creds) { + if (o.yes || o.json || !interactive()) { + if (o.json) out(JSON.stringify({ ok: false, signed_in: false, needs_pro: true, run: "npx genclass login" })); + else { + out(); + warn("Sign in first: npx genclass login. The genclass command comes with Pro; its free trial starts right after the sign-in."); + } + return 1; + } + out(); + note("Sign in to genclass.dev first. Pro, with its free trial, comes right after."); + const signIn = deps.signIn ?? (await import("./actions.mjs")).signIn; + if ((await signIn(o)) !== "done") return 1; + creds = readCredentials(); + if (!creds) return 1; + } + const me = await fetchOverview(creds, deps); + // a server that cannot be reached, or one that does not say, locks nothing here (its own routes still do) + if (!me.ok || !me.cli || me.cli.paid) { + if (me.ok && me.cli?.paid) seen = true; + return 0; + } + if (await startPro(me.cli, me.offer ?? null, o, deps)) { + seen = true; + out(); + return 0; + } + return 1; +} + +/** + * After `genclass login`: Pro comes right after the sign-in. An account with it hears so in one line; one without it + * sees the paywall (startPro). Never changes the login's exit code. Returns whether the account has Pro now (true + * too when the server does not say or cannot be reached: nothing is locked then). + */ +export async function afterLogin(o = {}, deps = {}) { + const creds = readCredentials(); + if (!creds) return false; + const me = await fetchOverview(creds, deps); + if (!me.ok || !me.cli) return true; + if (me.cli.paid) { + seen = true; + if (!o.json) note(`${clean(me.plan && me.plan.id !== "free" ? me.plan.name : "Pro", 20)} is on: every genclass command works. Next: npx genclass`); + return true; + } + if (await startPro(me.cli, me.offer ?? null, o, deps)) { + seen = true; + note("Next: npx genclass"); + return true; + } + return false; +} diff --git a/packages/cli/src/session.mjs b/packages/cli/src/session.mjs index 78bb5b8c..f1210d64 100644 --- a/packages/cli/src/session.mjs +++ b/packages/cli/src/session.mjs @@ -6,11 +6,13 @@ // told so and, with someone at the terminal, signed in once more on the spot (one approval in the browser), then // the same request is made again. Nothing is retried otherwise. // - No dead end: every refusal names the next step, as a command that runs as typed or an address to open. When -// the plan is why, it names the plan and the command that starts it. +// the plan is why, it names the plan and the command that starts it; when the account has no Pro (the genclass +// command is Pro's), the checkout of its free trial. import { readCredentialsFor } from "../../runtime/bin/lib/account.mjs"; import { c, interactive, out } from "../../runtime/bin/lib/ui.mjs"; import { signIn } from "./actions.mjs"; +import { proLine } from "./paywall.mjs"; import { bad, clean, note, proBox, typed, warn } from "./tui.mjs"; /** @@ -40,6 +42,17 @@ export async function asking(o, creds, call, deps = {}) { * `what`: the thing in a few words, capitalised ("Sending alerts"); `command`: what was typed after `genclass` */ export function refused(r, ctx) { + // the genclass command is Pro's (paywall.mjs): the one way on, whatever was asked + if (r.needsPro) { + if (ctx.json) out(JSON.stringify({ ok: false, error: "the genclass command comes with Pro", needs_pro: true, checkout_url: r.needsPro.checkoutUrl, trial_days: r.needsPro.trialDays, run: "npx genclass upgrade" })); + else { + out(); + warn(proLine(r.needsPro)); + out(` ${c.gray("Start it here")} ${c.cyan("npx genclass upgrade")}`); + out(); + } + return 1; + } if (ctx.json) { out(JSON.stringify({ ok: false, error: r.reauth ? "sign in again" : r.upgradeUrl ? "not in this plan" : r.missing ? "this genclass.dev has no such route" : r.reason, ...(r.reauth ? { run: "npx genclass login" } : {}), ...(r.upgradeUrl ? { upgrade_url: r.upgradeUrl, run: "npx genclass plan" } : {}) })); return 1; diff --git a/packages/cli/src/setup.mjs b/packages/cli/src/setup.mjs index 13b2e147..c0e94eb5 100644 --- a/packages/cli/src/setup.mjs +++ b/packages/cli/src/setup.mjs @@ -21,7 +21,7 @@ import { join, resolve } from "node:path"; import { detectProject } from "../../runtime/bin/lib/detect.mjs"; import { c, interactive, out } from "../../runtime/bin/lib/ui.mjs"; -import { addRuntime, addWorkflow, choosePlan, connectAgent, connectGithubApp, signIn, watchFirstPage } from "./actions.mjs"; +import { addRuntime, addWorkflow, chooseAppPlan, choosePlan, connectAgent, connectGithubApp, signIn, watchFirstPage } from "./actions.mjs"; import { brand, canDraw } from "./art.mjs"; import { BUILD } from "./assets.mjs"; import { appsBelow, planName, readState, stateJson } from "./state.mjs"; @@ -169,8 +169,8 @@ async function walk(o, deps, auto) { show(state, details, !auto && !welcomed); welcomed = true; if (!step) break; - // the plan step is its own screen and asks its own question (the card, then Enter or f) - if (step.id === "plan" && !auto) { + // Pro and the app's plan page are their own screens and ask their own question (the card, then Enter) + if ((step.id === "plan" || step.id === "app-plan") && !auto) { go = true; break; } @@ -221,13 +221,14 @@ async function walk(o, deps, auto) { const so = { ...o, confirmed: true }; let result = "skipped"; if (step.id === "account") result = await signIn(so); - else if (step.id === "plan") { - result = await choosePlan(state, so); + else if (step.id === "plan" || step.id === "app-plan") { + result = step.id === "plan" ? await choosePlan(state, so) : await chooseAppPlan(state, so); + // nothing after Pro works without it (paywall.mjs), and an app waits for its plan if (result === "quit") { out(); - note("Stopped. Run npx genclass again any time: it continues from here."); + if (!auto) note("Stopped. Run npx genclass again any time: it continues from here."); out(); - return 0; + return auto ? 1 : 0; } } else if (step.id === "runtime") result = await addRuntime(state, so); diff --git a/packages/cli/src/state.mjs b/packages/cli/src/state.mjs index b82d9770..1d68a5bf 100644 --- a/packages/cli/src/state.mjs +++ b/packages/cli/src/state.mjs @@ -196,6 +196,33 @@ export async function readState(o = {}, deps = {}) { human: "approve the sign-in in your browser", }); + // 1b. Pro: the genclass command is Pro's (the owner's call, 2026-11-13: "let's make the entire CLI paid"). Right + // after the sign-in, an account without it (Pro paid or in its trial, GenClass's grant, a place in a paying team) + // starts it in the browser: the free trial first, the card on Stripe. paywall.mjs asks before every command, so + // nothing after this step runs without it. A server that does not say (`cli`), or sells nothing here, has no step. + const cli = me?.cli ?? null; + steps.push({ + id: "plan", + title: "Pro", + doing: cli && !cli.paid ? (cli.trialDays ? `Start your ${cli.trialDays}-day free trial` : `Start ${cli.plan}`) : "Pro", + state: !me ? (creds && offline !== "expired" ? "optional" : "todo") : !cli ? "optional" : cli.paid ? "done" : "todo", + detail: !me + ? "" + : cli && !cli.paid + ? `${cli.price ?? cli.plan}${cli.trialDays ? `, ${cli.trialDays} days free` : ""}` + : cli?.paid && me.plan?.id === "free" + ? "through your team's plan" + : me.plan + ? me.plan.name + : "", + why: cli && !cli.paid ? `Every genclass command comes with ${cli.plan}${cli.trialDays ? `, ${cli.trialDays} days free to start` : ""}.` : "Every genclass command comes with Pro.", + more: `${cli && !cli.paid && cli.trialDays ? `The trial opens in your browser and the card is asked for there, on Stripe: nothing is charged if you cancel before the ${cli.trialDays} days end. ` : "Pro opens in your browser and is paid there, on Stripe. "}Free stays on the website: one app and its totals. Plans and prices: genclass.dev/pricing.`, + run: "npx genclass upgrade", + human: cli && !cli.paid ? `start ${cli.plan} in your browser: ${cli.checkoutUrl}` : null, + ...(cli ? { cli } : {}), + after: "account", + }); + // 2. the runtime in this app const linked = !!st.token && (!me || !!app); // added and in the account, but on Vite 7 or earlier without the build setting the model's worker needs (vite.mjs) @@ -232,58 +259,37 @@ export async function readState(o = {}, deps = {}) { human: null, }); - // 2b. the plan of this app. Plans are per project (genclass.dev's billing/plans.json version 4): once the app is in - // the account, the command opens the app's plan page in the browser, the paid plan large and "Try for free - // (limited)" small under it, and waits for the choice there. An app past the ones Free covers has only the paid - // plan. An app with a plan, an app of a team the account is in (its owner chooses), a server that sells nothing - // and a signed-out person have nothing to choose, so the step does not show. A server from before plans per - // project answers for the whole account: the plan is then chosen once, in the terminal, as it was. - const own = app && !app.shared ? app : null; - const pp = own?.plan ?? null; - const needs = own?.needsPlan === true; - const hasPlan = pp ? pp.id !== "free" || !!pp.source : !!me?.plan && me.plan.id !== "free"; - const offer = - me?.offer && !hasPlan && !app?.shared && (!pp || own.planUrl) - ? pp - ? { ...me.offer, checkoutUrl: `${own.planUrl}&from=cli`, project: { id: own.id, name: own.name, token: own.token }, needs } - : me.offer - : null; - const choseFree = pp ? own.planChosen === true && !needs : notes.plan === "free"; - const limits = offer ? freeLimits(offer.free) : ""; + // 2b. this app's plan page (genclass.dev/account/billing?project=): every new app of the account sees it once + // (the owner's call, 2026-11-13: the paywall for everyone, and a paying account clicks Continue with Pro), and an + // app past the ones its plan covers waits there (past Free's one: Pro; past Pro's: Enterprise, by email). An app of + // a team the account is in has nothing to choose: its owner does. + const appOffer = appPlanOffer(app, me?.offer ?? null, accountPaid(me)); steps.push({ - id: "plan", - title: "Plan", - doing: offer?.project ? "Choose this app's plan" : "Choose a plan", - state: !offer ? "optional" : choseFree ? "done" : "todo", - detail: !me - ? "" - : !offer - ? pp - ? `${pp.name}${pp.source === "comp" ? " (set up by GenClass)" : ""}` - : app?.shared - ? "the team's owner chooses it" - : me.plan - ? `${me.plan.name} plan` - : "" - : needs - ? `Free covers ${offer.free.apps === 1 ? "one app" : `${offer.free.apps ?? 1} apps`}: this one needs ${offer.plan.name}` - : choseFree - ? `${offer.free.name} for now (limited) · ${offer.plan.name} is ${offer.price}` - : `${offer.plan.name}, ${offer.price}`, - why: !offer - ? pp ? "This app's plan." : "The plan your account is on." - : needs - ? `Free covers ${offer.free.apps === 1 ? "one app" : `${offer.free.apps ?? 1} apps`} and this is one more. ${offer.plan.name} is ${offer.price}.` - : `${offer.plan.name} is ${offer.price}. Or try ${offer.free.name} for now (limited).`, - more: !offer - ? "Plans and prices: genclass.dev/pricing." - : offer.project - ? `The app's plan page opens in your browser: ${offer.plan.name}, paid there on Stripe, or ${offer.free.name} for now${limits ? ` (${limits})` : ""}. Every app has its own plan: genclass.dev/account/billing.` - : `${offer.plan.name} opens in your browser and is paid there, on Stripe. ${offer.free.name} keeps what finds, shows and fixes the problems${limits ? `: ${limits}` : ""}. You can change plan any time at genclass.dev/account/billing.`, + id: "app-plan", + title: "App plan", + doing: appOffer?.current ? `Continue with ${appOffer.current} for this app` : "Choose this app's plan", + state: appOffer ? "todo" : "optional", + detail: !me || !app?.plan + ? (app?.shared ? "the team's owner chooses it" : "") + : appOffer + ? appOffer.needs + ? appOffer.paying + ? `${appOffer.current ?? "Your plan"} covers no more apps` + : `Free covers ${appOffer.free.apps === 1 ? "one app" : `${appOffer.free.apps ?? 1} apps`}: this one needs ${appOffer.plan.name}` + : appOffer.current + ? `covered by ${appOffer.current}: continue on its plan page` + : `${appOffer.plan.name}, ${appOffer.price}` + : `${app.plan.name}${app.plan.source === "comp" ? " (set up by GenClass)" : ""}`, + why: appOffer?.needs && appOffer.paying + ? "Your plan covers no more apps. More apps come with Enterprise, by email." + : appOffer?.current + ? `Every new app has its plan page once. ${appOffer.current} covers this one: continue with it there.` + : "This app's plan, on its page in your browser.", + more: "Every app has its plan page: genclass.dev/account/billing. Plans and prices: genclass.dev/pricing.", run: "npx genclass setup", - human: offer && !choseFree ? `choose ${offer.project ? "this app's plan" : "a plan"} in your browser: ${offer.checkoutUrl}` : null, - ...(offer ? { offer } : {}), - // the plan is the app's: the walk asks once the app is in the account + human: appOffer ? `open this app's plan page: ${appOffer.checkoutUrl}` : null, + ...(appOffer ? { offer: appOffer } : {}), + // the plan page is the app's: the walk asks once the app is in the account after: "runtime", }); @@ -380,7 +386,7 @@ export async function readState(o = {}, deps = {}) { cwd, host: host.overridden && !host.error ? host.host : null, app: { name: app?.name ?? project.name ?? null, framework: project.label || null, isApp, token: st.token ?? null, dashboardUrl: app?.dashboard_url ?? st.local?.dashboardUrl ?? null, site: notes.site ?? null, ...(isApp ? {} : { below: appsBelow(cwd) }) }, - account: me ? { label: accountLabel(me.account), plan: me.plan, suggestions: me.suggestions, billingUrl: me.billingUrl, github: me.github, offer: me.offer ?? null } : null, + account: me ? { label: accountLabel(me.account), plan: me.plan, suggestions: me.suggestions, billingUrl: me.billingUrl, github: me.github, offer: me.offer ?? null, cli: me.cli ?? null } : null, signedIn: !!me, /** the account's apps as genclass.dev lists them ([] signed out) */ projects: me?.projects ?? [], @@ -395,6 +401,26 @@ export async function readState(o = {}, deps = {}) { }; } +/** The account has a plan (paid, in its trial, a grant) or, as far as the command goes, a paying team. */ +export const accountPaid = (me) => !!me && (me.cli ? me.cli.paid : !!me.plan && me.plan.id !== "free"); + +/** + * What one app's plan page is for, from the account's overview: null when the app has nothing to choose there (no + * such app, a team's app, a choice made and covered, a server that sells nothing). Else the offer the page shows: + * `current` (the plan that covers it: Continue), `needs` (past the apps its plan covers), `paying` (the account has + * a plan), and the page itself as `checkoutUrl`. + * @param {object | null} app the app as the overview lists it (api.mjs withPlan) + * @param {object | null} accountOffer the overview's `offer` (the account on Free) + * @param {boolean} paying accountPaid + */ +export function appPlanOffer(app, accountOffer, paying) { + if (!app || app.shared || !app.plan || !app.planUrl || (app.planChosen && !app.needsPlan)) return null; + const covered = !!app.plan.source && !app.needsPlan; + if (!covered && !accountOffer && !paying) return null; + const base = accountOffer ?? { plan: { name: "Pro" }, price: "", summary: null, features: [], free: { name: "Free", apps: null } }; + return { ...base, checkoutUrl: `${app.planUrl}&from=cli`, project: { id: app.id, name: app.name, token: app.token }, needs: app.needsPlan === true, current: covered ? app.plan.name : null, paying: !!paying }; +} + /** The plan of the app with `token` (plans are per project), else the account's: what a header shows. */ export const planName = (state, token = state?.app?.token) => (state?.projects ?? []).find((p) => p.token === token && p.plan)?.plan?.name ?? state?.account?.plan?.name ?? null; diff --git a/packages/cli/src/tui.mjs b/packages/cli/src/tui.mjs index 432ce8b9..f1ebf3de 100644 --- a/packages/cli/src/tui.mjs +++ b/packages/cli/src/tui.mjs @@ -181,10 +181,10 @@ export function proBox(what, lock = {}) { if (lock.upgradeUrl) out(` ${c.cyan(clean(lock.upgradeUrl, 200))}`); } -export function planCard(offer, app) { +export function planCard(offer, app, heading = null) { const inner = width() - 6; const B = unicode ? { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" } : { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" }; - const title = ` ${c.bold(app ? `A plan for ${clean(app, 30)}` : "A plan for this project")} `; + const title = ` ${c.bold(heading ? clean(heading, 50) : app ? `A plan for ${clean(app, 30)}` : "A plan for this project")} `; const row = (text = "") => out(` ${brand(B.v)} ${text}${" ".repeat(Math.max(0, inner - 3 - visible(text)))}${brand(B.v)}`); const room = inner - 6; out(); diff --git a/packages/cli/test/actions.test.ts b/packages/cli/test/actions.test.ts index e0699574..089397fe 100644 --- a/packages/cli/test/actions.test.ts +++ b/packages/cli/test/actions.test.ts @@ -268,7 +268,7 @@ describe("genclass: the command line (src/cli.mjs, src/main.mjs)", () => { expect(status).toContain("GenClass"); expect(status).toContain("shop"); expect(status).toContain("Next Sign in"); - expect(status).toContain("Later Runtime"); + expect(status).toContain("Later Pro • Runtime"); expect(status).toContain("Every part: npx genclass status --all"); // the full list on request: every part of GenClass by name const all = execFileSync(process.execPath, [main, "status", "--all", "--cwd", dir], { env, encoding: "utf8" }); diff --git a/packages/cli/test/server-account.test.ts b/packages/cli/test/server-account.test.ts index 3385629f..5aefc45a 100644 --- a/packages/cli/test/server-account.test.ts +++ b/packages/cli/test/server-account.test.ts @@ -90,23 +90,23 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { it("Free: the limits and what is used, what the plan leaves out, and the paid plan with where it starts", async () => { const { env } = setup(LIVE); const me = await account(env); - expect(await fetchAccount(me.creds, me.deps)).toMatchObject({ ok: true, plan: { id: "free", name: "Free", price: null }, limits: { apps: 3, historyDays: 7, seats: 1, suggestionsPerDay: 3 }, used: { apps: 1, seats: 1 }, includes: { analytics: false, forwarding: false, export: false } }); + expect(await fetchAccount(me.creds, me.deps)).toMatchObject({ ok: true, plan: { id: "free", name: "Free", price: null }, limits: { apps: 1, historyDays: 7, seats: 1, suggestionsPerDay: 3 }, used: { apps: 1, seats: 1 }, includes: { analytics: false, forwarding: false, export: false } }); const { text, code } = await said(() => plan(me.o, me.deps)); expect(code).toBe(0); expect(tooWide(text)).toEqual([]); const f = flat(text); expect(f).toContain("Plan Free"); - expect(f).toContain("Apps 1 of 3"); + expect(f).toContain("Apps 1 of 1"); expect(f).toContain("History 7 days"); expect(f).toContain("Model runs 0 of 3 used today"); expect(f).toContain("Not included visitor analytics, alerts to Slack, Sentry and others, the stored events as a file, private repositories"); - // the paid plan's card, in the server's words, and (nobody at a terminal) where it starts + // the paid plan's card, in the server's words, and (nobody at a terminal) where it starts: the genclass command is + // Pro's, so the account's own checkout, its trial first expect(f).toContain("P R O"); - expect(f).toContain("$15 / project / month"); - // plans are per project: the account's one app on Free has its own plan page - expect(text).toContain(`${HOST}/account/billing?project=${me.app.id}&from=cli`); + expect(f).toContain("$15 / developer / month"); + expect(f).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${HOST}/account/billing?plan=team&start=1`); expect(f).toContain("Apps and their plans Shop no plan chosen"); - expect(JSON.parse((await said(() => plan({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: true, plan: { id: "free" }, limits: { apps: 3 }, offer: { plan: { id: "team" } } }); + expect(JSON.parse((await said(() => plan({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: true, plan: { id: "free" }, limits: { apps: 1 }, offer: { plan: { id: "team" } }, cli: { paid: false, checkout_url: `${HOST}/account/billing?plan=team&start=1`, trial_days: 3 } }); }); it("Free says so after a command, with the way up; profile and upgrade name the app on Free and its plan page", async () => { @@ -115,7 +115,7 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { const line = await said(async () => ((await freeLine(me.o, me.deps)) ? 0 : 1)); expect(line.code).toBe(0); expect(tooWide(line.text)).toEqual([]); - expect(flat(line.text)).toContain("You are on Free, which has limits (3 apps, 100 live fixes a month, 7 days of history). Pro has none, 3 days free, then $15 / project / month. Upgrade now: npx genclass upgrade"); + expect(flat(line.text)).toContain("You are on Free (1 app, 100 live fixes a month, 7 days of history), on the website. Every genclass command comes with Pro, 3 days free, then $15 / developer / month. Start it: npx genclass upgrade"); // profile: who, the plan, each app's plan, and the choices (nobody at a terminal: as commands) const p = await said(() => profile(me.o, me.deps)); expect(p.code).toBe(0); @@ -123,17 +123,17 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { const f = flat(p.text); expect(f).toContain("Profile"); expect(f).toContain("Signed in as ada@example.com"); - expect(f).toContain("Plan Free (limited)"); + expect(f).toContain("Plan Free (website only: the genclass command comes with Pro)"); expect(f).toContain("Apps and their plans Shop no plan chosen"); - expect(f).toContain("Upgrade Shop to Pro $15 / project / month npx genclass upgrade"); + expect(f).toContain("Start the 3-day free trial $15 / developer / month npx genclass upgrade"); expect(f).toContain("Sign out npx genclass logout"); - expect(JSON.parse((await said(() => profile({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: true, plan: { id: "free" }, apps: [{ name: "Shop", plan: { id: "free" } }], upgrade_url: `${HOST}/account/billing?project=${me.app.id}&from=cli` }); - // upgrade: the paid plan's card and the app's plan page + expect(JSON.parse((await said(() => profile({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: true, plan: { id: "free" }, apps: [{ name: "Shop", plan: { id: "free" } }], upgrade_url: `${HOST}/account/billing?plan=team&start=1`, cli: { paid: false } }); + // upgrade: Pro's card and its checkout, for the account (the genclass command is Pro's) const u = await said(() => upgrade(me.o, me.deps)); expect(u.code).toBe(0); expect(tooWide(u.text)).toEqual([]); - expect(flat(u.text)).toContain("A plan for Shop"); - expect(u.text).toContain(`${HOST}/account/billing?project=${me.app.id}&from=cli`); + expect(flat(u.text)).toContain("Start your 3-day free trial"); + expect(flat(u.text)).toContain(`start your 3-day free trial at ${HOST}/account/billing?plan=team&start=1`); // on Team: no line, nothing to upgrade sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_id = 'sub_s', subscription_status = 'active' WHERE id = ?").run(me.app.id); expect(await freeLine(me.o, me.deps)).toBe(false); @@ -145,12 +145,12 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { const me = await account(env); team3(sql, me.account.id); const f = flat((await said(() => plan(me.o, me.deps))).text); - expect(f).toContain("Plan Pro $15 / project / month"); + expect(f).toContain("Plan Pro $15 / developer / month"); expect(f).toContain("Renews 2026-12-13"); - // Team is per project, everyone on it included - expect(f).toContain("People 1 • no limit"); + // Pro is per developer (3 seats paid here) and covers up to 3 apps + expect(f).toContain("People 1 of 3"); expect(f).toContain("Apps and their plans Shop Pro"); - expect(f).toContain("Apps 1 • no limit"); + expect(f).toContain("Apps 1 of 3"); expect(f).toContain("Includes visitor analytics, alerts to Slack, Sentry and others, the stored events as a file, private repositories"); expect(f).not.toContain("P R O"); // a sign-in the real server calls current is left alone by `genclass login` @@ -174,7 +174,9 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { expect(free).toContain("npx genclass plan"); const no = await said(() => team({ ...me.o, args: ["invite"] }, me.deps)); expect(no.code).toBe(1); - expect(flat(no.text)).toContain("A team comes with Pro."); + // the genclass command is Pro's: the invite, like every command, says where Pro starts + expect(flat(no.text)).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${HOST}/account/billing?plan=team&start=1`); + expect(flat(no.text)).toContain("npx genclass upgrade"); team3(sql, me.account.id); const made = await said(() => team({ ...me.o, args: ["invite"] }, me.deps)); expect(made.code).toBe(0); @@ -183,7 +185,7 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { expect(link).toBeTruthy(); expect(flat(made.text)).toContain("It works once"); const list = flat((await said(() => team(me.o, me.deps))).text); - expect(list).toContain("Team 1 person"); + expect(list).toContain("Team 1 of 3 seats used"); expect(list).toContain("ada@example.com owner"); expect(list).toContain("an invite link is open until"); expect(list).toContain("npx genclass team invite"); @@ -203,9 +205,9 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { expect(flat(made.text)).toMatch(/Made Blog two\. Project key gc_[A-Za-z0-9]{22}/); await said(() => apps({ cwd: me.dir, args: ["new", "Third"] }, me.deps)); const over = await said(() => apps({ cwd: me.dir, args: ["new", "Fourth"] }, me.deps)); - // a fourth app is made, and needs a plan of its own + // a second app (and a fourth) is made, and needs Pro: Free covers one expect(over.code).toBe(0); - expect(flat(over.text)).toContain("Free covers 3 apps: this one needs a paid plan."); + expect(flat(over.text)).toContain("Free covers one app: this one needs Pro."); expect(over.text).toMatch(/account\/billing\?project=[0-9a-f]+/); expect((await said(() => apps({ cwd: me.dir, args: ["new"] }, me.deps))).code).toBe(2); expect(JSON.parse((await said(() => apps({ cwd: me.dir, json: true }, me.deps))).text).apps.map((a: any) => a.name)).toEqual(["Blog two", "Fourth", "Shop", "Third"]); @@ -232,23 +234,21 @@ describe("genclass connections (src/manage.mjs connections)", () => { it("Free sees the destinations and which plan sends to them; a change is refused with the command that starts the plan", async () => { const { env } = setup(LIVE); const me = await account(env); + // the genclass command is Pro's: Free is told where Pro starts, and nothing else const { text, code } = await said(() => connections(me.o, me.deps)); - expect(code).toBe(0); + expect(code).toBe(1); expect(tooWide(text)).toEqual([]); const f = flat(text); - for (const d of ["Slack", "Sentry", "PostHog", "Datadog", "Webhook"]) expect(f).toContain(`${d} not set up`); - expect(f).toContain("Alerts come with the Pro plan. npx genclass plan"); - expect(f).toContain("Private link off"); - // setting one up on Free: the plan is why, said before anything is asked for, with the command that starts it + expect(f).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${HOST}/account/billing?plan=team&start=1`); + expect(f).toContain("npx genclass upgrade"); + expect(f).not.toContain("not set up"); + // setting one up on Free: the same, said before anything is asked for const no = await typing([], () => said(() => connections({ ...me.o, args: ["add", "slack"] }, me.deps))); expect(no.code).toBe(1); - expect(flat(no.text)).toContain("Sending alerts comes with Pro."); + expect(flat(no.text)).toContain("The genclass command comes with Pro"); expect(flat(no.text)).toContain("npx genclass upgrade"); expect(no.text).not.toContain("Slack incoming webhook URL"); - // a destination that was never set up cannot be turned on: the server's own sentence - const never = await said(() => connections({ ...me.o, args: ["on", "slack"] }, me.deps)); - expect(never.code).toBe(1); - expect(flat(never.text)).toContain("slack is not set up for this app"); + expect(JSON.parse((await said(() => connections({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: false, needs_pro: true, checkout_url: `${HOST}/account/billing?plan=team&start=1`, run: "npx genclass upgrade" }); }); it("Team: add one (what is typed is never shown), test it, turn it off, remove it", async () => { @@ -256,6 +256,10 @@ describe("genclass connections (src/manage.mjs connections)", () => { const sent: string[] = []; const me = await account(env, async (url) => (sent.push(url), new Response("ok", { status: 200 }))); team3(sql, me.account.id); + // a destination that was never set up cannot be turned on: the server's own sentence + const never = await said(() => connections({ ...me.o, args: ["on", "slack"] }, me.deps)); + expect(never.code).toBe(1); + expect(flat(never.text)).toContain("slack is not set up for this app"); // the address is typed at a hidden prompt, then Enter says yes to the test record const add = await typing([SLACK, ""], () => said(() => connections({ ...me.o, args: ["add", "slack"] }, me.deps))); expect(add.code).toBe(0); @@ -297,7 +301,7 @@ describe("genclass export and funnels (src/manage.mjs)", () => { const me = await account(env); const no = await said(() => exportEvents(me.o, me.deps)); expect(no.code).toBe(1); - expect(flat(no.text)).toContain("The stored events as a file comes with Pro."); + expect(flat(no.text)).toContain("The genclass command comes with Pro"); expect(flat(no.text)).toContain("npx genclass upgrade"); team3(sql, me.account.id); resetOwnerCache(); @@ -329,7 +333,8 @@ describe("genclass export and funnels (src/manage.mjs)", () => { const me = await account(env); const no = await said(() => funnels({ ...me.o, args: ["add", "Checkout", "/cart", "/checkout"] }, me.deps)); expect(no.code).toBe(1); - expect(flat(no.text)).toContain("Funnels (part of visitor analytics) comes with Pro."); + expect(flat(no.text)).toContain("The genclass command comes with Pro"); + expect(flat(no.text)).toContain("npx genclass upgrade"); team3(sql, me.account.id); expect(flat((await said(() => funnels(me.o, me.deps))).text)).toContain("No funnel yet."); const add = await said(() => funnels({ ...me.o, args: ["add", "Checkout", "/cart", "/checkout", "/thanks"] }, me.deps)); @@ -449,11 +454,11 @@ describe("genclass ask (src/ask.mjs)", () => { const free = await account(env); const deps = { ...free.deps, readPrefs: () => ({ askTold: true }) }; // on Free, asking is Pro's: the answer says which plan, its trial and price, and the command that starts it - expect(await askModel(free.creds, { id: free.app.id, question: "How is it?" }, free.deps)).toMatchObject({ ok: false, status: 403, lock: { plan: "Pro", trialDays: 3, price: "$15 / project / month" } }); + expect(await askModel(free.creds, { id: free.app.id, question: "How is it?" }, free.deps)).toMatchObject({ ok: false, status: 403, lock: { plan: "Pro", trialDays: 3, price: "$15 / developer / month" } }); const locked = await said(() => ask({ ...free.o, question: "How is it?" }, deps)); expect(locked.code).toBe(1); expect(tooWide(locked.text)).toEqual([]); - expect(flat(locked.text)).toContain("A question to the model comes with Pro. 3 days free, then $15 / project / month. Cancel any time. Start: npx genclass upgrade"); + expect(flat(locked.text)).toContain("A question to the model comes with Pro. 3 days free, then $15 / developer / month. Cancel any time. Start: npx genclass upgrade"); const me = await pro(sql, free); expect(await askModel(me.creds, { id: me.app.id, question: "How is it?" }, me.deps)).toMatchObject({ ok: true, answer: null, why: "no-model" }); const none = await said(() => ask({ ...me.o, question: "How is it?" }, deps)); diff --git a/packages/cli/test/server.test.ts b/packages/cli/test/server.test.ts index fb175faa..32fd1e3c 100644 --- a/packages/cli/test/server.test.ts +++ b/packages/cli/test/server.test.ts @@ -84,13 +84,13 @@ describe("genclass against the collector (src/api.mjs, telemetry-worker/src/cli. const deps = { runGit: () => null, fetchFn: through(env), now: now().getTime() }; // Free: the totals, and the way to the rest (the problems, their pages and the fix prompts are Pro's) const data = await fetchApp(me.creds, app.id, "7d", deps); - expect(data).toMatchObject({ ok: true, name: "shop", range: "7d", totals: { found: 2, checks: 0 }, problems: [], visitors: null, visitorsPlan: { plan: "Pro", upgradeUrl: `${HOST}/account/billing` }, problemsPlan: { plan: "Pro", trialDays: 3, price: "$15 / project / month", upgradeUrl: `${HOST}/account/billing?project=${app.id}` }, dashboardUrl: `${HOST}/account/apps/${app.id}` }); + expect(data).toMatchObject({ ok: true, name: "shop", range: "7d", totals: { found: 2, checks: 0 }, problems: [], visitors: null, visitorsPlan: { plan: "Pro", upgradeUrl: `${HOST}/account/billing` }, problemsPlan: { plan: "Pro", trialDays: 3, price: "$15 / developer / month", upgradeUrl: `${HOST}/account/billing?project=${app.id}` }, dashboardUrl: `${HOST}/account/apps/${app.id}` }); let code = -1; const freeView = await printed(async () => (code = await dashboard({ cwd: dir }, deps))); expect(code).toBe(0); expect(freeView).toMatch(/Problems found\s+2/); expect(freeView.replace(/\s+/g, " ")).toContain("The problems, the pages they happen on and a fix prompt for each come with Pro. Free shows the totals."); - expect(freeView.replace(/\s+/g, " ")).toContain("3 days free, then $15 / project / month. Cancel any time. Start: npx genclass upgrade"); + expect(freeView.replace(/\s+/g, " ")).toContain("3 days free, then $15 / developer / month. Cancel any time. Start: npx genclass upgrade"); expect(freeView).not.toContain("the same request went out twice"); // on Pro (here GenClass's grant for this app): every problem sql.db.prepare("UPDATE projects SET comp_until = '2027-12-31T00:00:00.000Z', comp_plan = 'team' WHERE id = ?").run(app.id); @@ -150,7 +150,7 @@ describe("genclass against the collector (src/api.mjs, telemetry-worker/src/cli. ideasLocked: 2, designsLocked: 1, upgradeUrl: `${HOST}/account/billing?plan=team&from=cli`, - upgradePlan: { plan: "Pro", price: "$15 / project / month", trialDays: 3 }, + upgradePlan: { plan: "Pro", price: "$15 / developer / month", trialDays: 3 }, limited: false, }; expect(free).toMatchObject({ ...shut, cachedAt: null, remainingToday: 2 }); diff --git a/packages/cli/test/state.test.ts b/packages/cli/test/state.test.ts index 5d8d06b9..8e1cee2c 100644 --- a/packages/cli/test/state.test.ts +++ b/packages/cli/test/state.test.ts @@ -3,9 +3,10 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { saveCredentials } from "../../runtime/bin/lib/account.mjs"; +import { deleteCredentials, saveCredentials } from "../../runtime/bin/lib/account.mjs"; import { run as runInit } from "../../runtime/bin/lib/init.mjs"; -import { choosePlan, planNow } from "../src/actions.mjs"; +import { chooseAppPlan, choosePlan, planNow } from "../src/actions.mjs"; +import { forgetPro, requirePro } from "../src/paywall.mjs"; import { status } from "../src/setup.mjs"; import { agentConfigs, appsBelow, genclassWorkflows, hasPlaywright, planName, readState, stateJson, WORKFLOW_PATH } from "../src/state.mjs"; import { renamedRepo } from "../src/git.mjs"; @@ -31,7 +32,7 @@ describe("genclass: the state of a project (src/state.mjs header)", () => { it("a folder that is no web app says so, and signing in is still the next step", async () => { const dir = tempDir(); const s = await readState({ cwd: dir }, { runGit: noGit }); - expect(states(s)).toEqual({ account: "next", plan: "optional", runtime: "blocked", dashboard: "optional", workflow: "optional", "github-app": "optional", agent: "optional", suggestions: "todo" }); + expect(states(s)).toEqual({ account: "next", plan: "todo", runtime: "blocked", "app-plan": "optional", dashboard: "optional", workflow: "optional", "github-app": "optional", agent: "optional", suggestions: "todo" }); expect(s.next?.id).toBe("account"); expect(s.steps.find((x) => x.id === "runtime")!.detail).toContain("no web app in this folder"); expect(s.app.isApp).toBe(false); @@ -44,7 +45,7 @@ describe("genclass: the state of a project (src/state.mjs header)", () => { const server = fakeServer({}); const s = await readState({ cwd: dir }, { runGit: noGit, fetchFn: server.fetchFn }); expect(server.calls).toEqual([]); - expect(states(s)).toEqual({ account: "next", plan: "optional", runtime: "todo", dashboard: "todo", workflow: "optional", "github-app": "optional", agent: "todo", suggestions: "todo" }); + expect(states(s)).toEqual({ account: "next", plan: "todo", runtime: "todo", "app-plan": "optional", dashboard: "todo", workflow: "optional", "github-app": "optional", agent: "todo", suggestions: "todo" }); expect(s.steps.find((x) => x.id === "runtime")!).toMatchObject({ run: "npx genclass init", detail: "Vite + React" }); expect(s.steps.find((x) => x.id === "workflow")!.detail).toBe("not a git repository yet"); expect(s.app).toMatchObject({ name: "shop", framework: "Vite + React", isApp: true, token: null }); @@ -217,7 +218,7 @@ describe("genclass: the state of a project (src/state.mjs header)", () => { expect(j.next).toMatchObject({ id: "runtime", title: "Add GenClass to this app", run: "npx genclass init", human: null }); // every step has a short name, what doing it is called, one sentence and a fuller one for (const x of s.steps) expect([x.title.length > 0, x.doing.length > 0, x.why.length > 0 && x.why.length < 100, x.more.length > 0], x.id).toEqual([true, true, true, true]); - expect(j.steps.map((x) => x.id)).toEqual(["account", "runtime", "plan", "dashboard", "workflow", "github-app", "agent", "suggestions"]); + expect(j.steps.map((x) => x.id)).toEqual(["account", "plan", "runtime", "app-plan", "dashboard", "workflow", "github-app", "agent", "suggestions"]); expect(j.steps[0]).toMatchObject({ state: "todo", run: "npx genclass login", human: "approve the sign-in in your browser" }); expect(j.complete).toBe(false); expect(JSON.stringify(j)).not.toContain(SESSION); @@ -327,110 +328,156 @@ describe("genclass: a repository that was renamed on GitHub (src/git.mjs renamed }); }); -describe("genclass: the plan step (src/state.mjs, src/actions.mjs choosePlan, src/tui.mjs planCard)", () => { +describe("genclass: Pro right after the sign-in, and each app's plan page (src/state.mjs, src/paywall.mjs, src/actions.mjs choosePlan, planNow, src/tui.mjs planCard)", () => { const OFFER = { - plan: { id: "team", name: "Team" }, + plan: { id: "team", name: "Pro" }, price: "$15 / developer / month", - summary: "Everything a team needs, per developer.", + summary: "Everything for up to 3 apps, per developer.", features: ["Every suggestion, with a prompt for your agent", "GitHub App on private repositories", "Visitor analytics", "Alerts to Sentry, PostHog, Datadog, Slack or a webhook"], checkout_url: `${HOST}/account/billing?plan=team&start=1`, - free: { name: "Free", apps: 3, history_days: 7, suggestions_shown: 3 }, + trial_days: 3, + free: { name: "Free", apps: 1, history_days: 7, suggestions_shown: 1 }, }; + const CHECKOUT = `${HOST}/account/billing?plan=team&start=1`; + const LOCKED = { paid: false, plan: "Pro", price: "$15 / developer / month", trial_days: 3, checkout_url: CHECKOUT }; + const PRO = { plan: { id: "team", name: "Pro" }, cli: { paid: true } }; const plain = (t: string) => t.replace(/\u001b\[[0-9;]*m/g, ""); + const flat = (t: string) => plain(t).replace(/\s+/g, " "); - it("an account on Free that can buy is asked once the app is in it; one with a plan, or signed out, is not", async () => { + it("the genclass command is Pro's: without it, Pro is the step right after the sign-in, before anything else", async () => { const dir = tempDir(); viteApp(dir); - // signed out: nothing to choose yet - expect((await readState({ cwd: dir }, { runGit: noGit })).steps.find((x) => x.id === "plan")).toMatchObject({ state: "optional" }); + // signed out: the sign-in first, Pro right after it + const out = await readState({ cwd: dir }, { runGit: noGit }); + expect(out.steps.map((x) => x.id).slice(0, 4)).toEqual(["account", "plan", "runtime", "app-plan"]); + expect(out.steps.find((x) => x.id === "plan")).toMatchObject({ state: "todo", after: "account" }); + expect(out.next?.id).toBe("account"); signIn(); - const offered = fakeServer({ "GET /api/cli/overview": { json: overview({ offer: OFFER }) } }); - const s = await readState({ cwd: dir }, { runGit: noGit, fetchFn: offered.fetchFn }); - // the plan is the app's: it comes after the runtime puts the app in the account - expect(s.steps.map((x) => x.id).slice(0, 3)).toEqual(["account", "runtime", "plan"]); - expect(s.next?.id).toBe("runtime"); - expect(s.steps.find((x) => x.id === "plan")).toMatchObject({ state: "todo", after: "runtime" }); + const locked = fakeServer({ "GET /api/cli/overview": { json: overview({ offer: OFFER, cli: LOCKED }) } }); + const s = await readState({ cwd: dir }, { runGit: noGit, fetchFn: locked.fetchFn }); + expect(s.next?.id).toBe("plan"); const step = s.steps.find((x) => x.id === "plan")!; - expect(step).toMatchObject({ state: "todo", doing: "Choose a plan", detail: "Team, $15 / developer / month", why: "Team is $15 / developer / month. Or try Free for now (limited)." }); - expect(step.more).toContain("3 apps, 7 days of history, your top 3 suggestions"); - expect(step.human).toBe(`choose a plan in your browser: ${HOST}/account/billing?plan=team&start=1`); - // a paid account, or a server that sells nothing, has no offer: the step does not show - const paid = fakeServer({ "GET /api/cli/overview": { json: overview({ plan: { id: "team", name: "Team" } }) } }); - const p = await readState({ cwd: dir }, { runGit: noGit, fetchFn: paid.fetchFn }); - expect(p.steps.find((x) => x.id === "plan")).toMatchObject({ state: "optional", detail: "Team plan" }); - expect(p.next?.id).toBe("runtime"); - // what the server sends is not trusted: a checkout address on another host is no offer - const evil = fakeServer({ "GET /api/cli/overview": { json: overview({ offer: { ...OFFER, checkout_url: "https://evil.example/pay" } }) } }); - expect((await readState({ cwd: dir }, { runGit: noGit, fetchFn: evil.fetchFn })).steps.find((x) => x.id === "plan")).toMatchObject({ state: "optional" }); + expect(step).toMatchObject({ title: "Pro", doing: "Start your 3-day free trial", detail: "$15 / developer / month, 3 days free", why: "Every genclass command comes with Pro, 3 days free to start.", run: "npx genclass upgrade", human: `start Pro in your browser: ${CHECKOUT}` }); + expect(step.more).toContain("nothing is charged if you cancel before the 3 days end"); + expect(step.more).toContain("Free stays on the website"); + expect(s.account?.cli).toEqual({ paid: false, plan: "Pro", price: "$15 / developer / month", trialDays: 3, checkoutUrl: CHECKOUT }); + // Pro (paid, in its trial, a grant): done, and the runtime is next + const paid = await readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview(PRO) } }).fetchFn }); + expect(paid.steps.find((x) => x.id === "plan")).toMatchObject({ state: "done", detail: "Pro" }); + expect(paid.next?.id).toBe("runtime"); + // a member of a paying team + const member = await readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ cli: { paid: true } }) } }).fetchFn }); + expect(member.steps.find((x) => x.id === "plan")).toMatchObject({ state: "done", detail: "through your team's plan" }); + // a server that does not say (older, self-hosted) locks nothing + const older = await readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview() } }).fetchFn }); + expect(older.steps.find((x) => x.id === "plan")).toMatchObject({ state: "optional" }); + expect(older.next?.id).toBe("runtime"); + // what the server sends is not trusted: a checkout on another host becomes its own billing page + const evil = await readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ cli: { ...LOCKED, checkout_url: "https://evil.example/pay" } }) } }).fetchFn }); + expect(evil.steps.find((x) => x.id === "plan")!.human).toBe(`start Pro in your browser: ${HOST}/account/billing`); }); - it("with nobody to ask (--yes, or no terminal) nothing is bought or opened: Free for now, remembered for this project", async () => { + it("with nobody to ask (--yes, --json, no terminal) nothing is opened or waited for: one line with the trial's checkout, and exit code 1", async () => { const dir = tempDir(); viteApp(dir); signIn(); - const offered = fakeServer({ "GET /api/cli/overview": { json: overview({ offer: OFFER }) } }); - const before = await readState({ cwd: dir }, { runGit: noGit, fetchFn: offered.fetchFn }); - const said = await printed(async () => expect(await choosePlan(before, { yes: true })).toBe("done")); - expect(said.replace(/\s+/g, " ")).toContain(`Free for now (limited: 3 apps, 7 days of history, your top 3 suggestions). Team, $15 / developer / month: ${HOST}/account/billing?plan=team&start=1`); - const after = await readState({ cwd: dir }, { runGit: noGit, fetchFn: offered.fetchFn }); - expect(after.steps.find((x) => x.id === "plan")).toMatchObject({ state: "done", detail: "Free for now (limited) · Team is $15 / developer / month" }); - expect(after.next?.id).toBe("runtime"); - // another project of the same account is asked for itself - const other = tempDir(); - viteApp(other); - expect((await readState({ cwd: other }, { runGit: noGit, fetchFn: offered.fetchFn })).steps.find((x) => x.id === "plan")!.state).toBe("todo"); - // no offer: nothing to do - expect(await choosePlan({ cwd: dir, steps: [{ id: "plan" }] } as never, { yes: true })).toBe("skipped"); + const fetchFn = fakeServer({ "GET /api/cli/overview": { json: overview({ offer: OFFER, cli: LOCKED }) } }).fetchFn; + const state = await readState({ cwd: dir }, { runGit: noGit, fetchFn }); + const said = await printed(async () => expect(await choosePlan(state, { yes: true })).toBe("quit")); + expect(flat(said)).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${CHECKOUT}`); + // the gate every command passes: the same line, exit code 1; --json says it as data + forgetPro(); + expect(flat(await printed(async () => expect(await requirePro({ yes: true }, { fetchFn })).toBe(1)))).toContain(`start your 3-day free trial at ${CHECKOUT}`); + expect(JSON.parse(await printed(async () => expect(await requirePro({ json: true }, { fetchFn })).toBe(1)))).toEqual({ ok: false, needs_pro: true, plan: "Pro", price: "$15 / developer / month", trial_days: 3, checkout_url: CHECKOUT }); + // Pro: in, and not asked again in this process + const paid = fakeServer({ "GET /api/cli/overview": { json: overview(PRO) } }); + expect(await requirePro({ yes: true }, { fetchFn: paid.fetchFn })).toBe(0); + expect(await requirePro({ yes: true }, { fetchFn: paid.fetchFn })).toBe(0); + expect(paid.calls).toHaveLength(1); + expect(await choosePlan(await readState({ cwd: dir }, { runGit: noGit, fetchFn: paid.fetchFn }), { yes: true })).toBe("skipped"); + // a server that does not say, or cannot be reached, locks nothing here (its own routes still answer 402) + forgetPro(); + expect(await requirePro({ yes: true }, { fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview() } }).fetchFn })).toBe(0); + forgetPro(); + expect(await requirePro({ yes: true }, { fetchFn: (async () => { throw new Error("offline"); }) as never })).toBe(0); + // signed out, with nobody to sign in: what to run, exit code 1 + forgetPro(); + deleteCredentials(); + expect(flat(await printed(async () => expect(await requirePro({ yes: true }, { fetchFn })).toBe(1)))).toContain("Sign in first: npx genclass login"); + }); + + it("every command but signing in, the plan, status, help and remove passes the gate first (src/cli.mjs FREE_COMMANDS)", async () => { + const { FREE_COMMANDS, run } = await import("../src/cli.mjs"); + expect(FREE_COMMANDS).toEqual(["login", "logout", "whoami", "plan", "upgrade", "profile", "status", "remove", "help", "version"]); + deleteCredentials(); + forgetPro(); + const dir = tempDir(); + viteApp(dir); + for (const cmd of ["dashboard", "problems", "suggest", "connections", "export", "init", "scan"]) { + const said = await printed(async () => expect(await run([cmd, "--yes", "--cwd", dir]), cmd).toBe(1)); + expect(flat(said), cmd).toContain("Sign in first: npx genclass login. The genclass command comes with Pro"); + } + // where things stand is not gated: it shows Pro as the step after the sign-in + expect(JSON.parse(await printed(async () => expect(await run(["status", "--json", "--cwd", dir])).toBe(0))).steps.map((x: { id: string }) => x.id).slice(0, 2)).toEqual(["account", "plan"]); + // a word a command does not take is told so before anything is asked of genclass.dev + const { vi } = await import("vitest"); + const spy = vi.spyOn(process.stderr, "write").mockImplementation((() => true) as never); + try { + expect(await run(["agent", "nonsense"])).toBe(2); + expect(await run(["analytics", "nonsense"])).toBe(2); + } finally { + spy.mockRestore(); + } }); - it("plans per project: the app's own plan page, Free chosen there or a payment closes the step, a fourth app has only the paid plan", async () => { + it("each app's plan page: a new app opens it once (Continue with Pro for a paying account), an app past what the plan covers waits there", async () => { const dir = tempDir(); viteApp(dir); await addRuntime(dir); signIn(); const url = `${HOST}/account/billing?project=p1`; - const app = (extra: object) => project({ id: "p1", plan: { id: "free", name: "Free", source: null }, needs_plan: false, plan_chosen: false, plan_url: url, ...extra }); - const read = async (p: object, offer: object | null = { ...OFFER, price: "$15 / project / month" }) => - readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ offer, projects: [p] }) } }).fetchFn }); + const app = (extra: object) => project({ id: "p1", plan: { id: "team", name: "Pro", source: "paid" }, needs_plan: false, plan_chosen: false, plan_url: url, ...extra }); + const read = async (p: object, over: object = PRO) => readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ ...over, projects: [p] }) } }).fetchFn }); + // Pro covers it, no choice yet: Continue with Pro, on its page const s = await read(app({})); - expect(s.next?.id).toBe("plan"); - const step = s.steps.find((x) => x.id === "plan")!; - expect(step).toMatchObject({ doing: "Choose this app's plan", detail: "Team, $15 / project / month", human: `choose this app's plan in your browser: ${url}&from=cli` }); - expect(step.offer).toMatchObject({ checkoutUrl: `${url}&from=cli`, project: { id: "p1" }, needs: false }); - expect(step.more).toContain("The app's plan page opens in your browser"); - // Free chosen on the page: done, and the next run does not ask - expect((await read(app({ plan_chosen: true }))).steps.find((x) => x.id === "plan")).toMatchObject({ state: "done", detail: "Free for now (limited) · Team is $15 / project / month" }); - // on Team, or granted: nothing to choose - expect((await read(app({ plan: { id: "team", name: "Team", source: "paid" } }), null)).steps.find((x) => x.id === "plan")).toMatchObject({ state: "optional", detail: "Team" }); - expect((await read(app({ plan: { id: "team", name: "Team", source: "comp" } }), null)).steps.find((x) => x.id === "plan")).toMatchObject({ state: "optional", detail: "Team (set up by GenClass)" }); - // a fourth app: only the paid plan, and choosing Free on the page does not close it - const fourth = (await read(app({ needs_plan: true, plan_chosen: true }))).steps.find((x) => x.id === "plan")!; - expect(fourth).toMatchObject({ state: "next", detail: "Free covers 3 apps: this one needs Team" }); - expect(fourth.offer.needs).toBe(true); + expect(s.next?.id).toBe("app-plan"); + const step = s.steps.find((x) => x.id === "app-plan")!; + expect(step).toMatchObject({ doing: "Continue with Pro for this app", detail: "covered by Pro: continue on its plan page", human: `open this app's plan page: ${url}&from=cli`, after: "runtime" }); + expect(step.offer).toMatchObject({ checkoutUrl: `${url}&from=cli`, project: { id: "p1" }, needs: false, current: "Pro", paying: true }); + // continued: nothing to choose + expect((await read(app({ plan_chosen: true }))).steps.find((x) => x.id === "app-plan")).toMatchObject({ state: "optional", detail: "Pro" }); + expect((await read(app({ plan: { id: "team", name: "Pro", source: "comp" }, plan_chosen: true }))).steps.find((x) => x.id === "app-plan")).toMatchObject({ detail: "Pro (set up by GenClass)" }); + // a fourth app on Pro: locked, more apps are Enterprise's; a choice made earlier does not close it + const fourth = (await read(app({ plan: { id: "free", name: "Free", source: null }, needs_plan: true, plan_chosen: true }))).steps.find((x) => x.id === "app-plan")!; + expect(fourth).toMatchObject({ state: "next", detail: "Your plan covers no more apps", why: "Your plan covers no more apps. More apps come with Enterprise, by email." }); + expect(fourth.offer).toMatchObject({ needs: true, paying: true, current: null }); // with nobody at a terminal the page is opened (here: it cannot be) and the step stays open - const said = await printed(async () => expect(await choosePlan(await read(app({})), { yes: true, noBrowser: true })).toBe("skipped")); - expect(said.replace(/\s+/g, " ")).toContain(`Open ${url}&from=cli`); + const said = await printed(async () => expect(await chooseAppPlan(await read(app({})), { yes: true, noBrowser: true })).toBe("skipped")); + expect(flat(said)).toContain(`Open ${url}&from=cli`); + expect(flat(said)).toContain("Your Pro plan covers shop: continue with it on its plan page."); // an app of a team the account is in: its owner chooses - expect((await read(project({ shared: true }))).steps.find((x) => x.id === "plan")).toMatchObject({ state: "optional", detail: "the team's owner chooses it" }); + expect((await read(project({ shared: true }))).steps.find((x) => x.id === "app-plan")).toMatchObject({ state: "optional", detail: "the team's owner chooses it" }); + // a server that sells nothing and an app on Free: nothing to choose + expect((await read(app({ plan: { id: "free", name: "Free", source: null } }), { cli: null })).steps.find((x) => x.id === "app-plan")).toMatchObject({ state: "optional" }); }); - it("an app with no plan chosen gets its plan page at once (after login, connect, init, apps new, and before its views); one with a plan or a choice does not", async () => { + it("a new app opens its plan page at once (after login, connect, init, apps new, and before its views); a choice made does not", async () => { const dir = tempDir(); viteApp(dir); await addRuntime(dir); signIn(); const url = `${HOST}/account/billing?project=p1`; - const app = (extra: object) => project({ id: "p1", plan: { id: "free", name: "Free", source: null }, needs_plan: false, plan_chosen: false, plan_url: url, ...extra }); - const read = (p: object) => readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ offer: { ...OFFER, price: "$15 / project / month" }, projects: [p] }) } }).fetchFn }); + const app = (extra: object) => project({ id: "p1", plan: { id: "team", name: "Pro", source: "paid" }, needs_plan: false, plan_chosen: false, plan_url: url, ...extra }); + const read = (p: object) => readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ ...PRO, projects: [p] }) } }).fetchFn }); // nobody at a terminal here: it says where the page is and goes on let r = ""; const said = await printed(async () => (r = await planNow({ cwd: dir }, { readState: async () => read(app({})) }))); expect(r).toBe("open"); - expect(said.replace(/\s+/g, " ")).toContain(`shop has no plan yet. Choose it in the browser: ${url}&from=cli`); + expect(flat(said)).toContain(`shop has no plan yet. Choose it in the browser: ${url}&from=cli`); // the app named by its id, the same expect(await planNow({ cwd: dir }, { readState: async () => read(app({})) }, "p1")).toBe("open"); - // nothing to choose: Free chosen with a reason, Pro paid, or GenClass's grant - for (const p of [app({ plan_chosen: true }), app({ plan: { id: "team", name: "Pro", source: "paid" } }), app({ plan: { id: "team", name: "Pro", source: "comp" } })]) { + // nothing to choose: Continue clicked, or GenClass's grant continued, or a team's app + for (const p of [app({ plan_chosen: true }), app({ plan: { id: "team", name: "Pro", source: "comp" }, plan_chosen: true }), project({ shared: true })]) { expect(await printed(async () => expect(await planNow({ cwd: dir }, { readState: async () => read(p) })).toBe("none"))).toBe(""); } // the header names this app's plan, not the account's best one @@ -443,18 +490,21 @@ describe("genclass: the plan step (src/state.mjs, src/actions.mjs choosePlan, sr try { for (const width of [58, 80]) { process.stdout.columns = width; - const card = plain(await printed(() => planCard({ plan: { name: "Team" }, price: OFFER.price, summary: OFFER.summary, features: OFFER.features }, "corner-bakery"))); + const card = plain(await printed(() => planCard({ plan: { name: "Pro" }, price: OFFER.price, summary: OFFER.summary, features: OFFER.features, trialDays: 3 }, null, "Start your 3-day free trial"))); const lines = card.split("\n").filter((l) => /[│╭╰]/.test(l)); expect(new Set(lines.map((l) => [...l].length)), `${width} columns`).toEqual(new Set([Math.max(60, width) - 2])); - expect(card).toContain("A plan for corner-bakery"); - expect(card).toContain("T E A M"); + expect(card).toContain("Start your 3-day free trial"); + expect(card).toContain("P R O"); expect(card).toContain("$15 / developer / month"); - expect(card).toContain("Everything a team needs, per developer."); + expect(card).toContain("Everything for up to 3 apps, per developer."); + expect(card.replace(/[│\s]+/g, " ")).toContain("3 days free, then $15 / developer / month. Cancel any time."); expect(card).toContain("✔ Visitor analytics"); expect(card.replace(/[│\s]+/g, " ")).toContain("✔ Alerts to Sentry, PostHog, Datadog, Slack or a webhook"); } + // an app's card says so + expect(plain(await printed(() => planCard({ plan: { name: "Pro" }, price: OFFER.price, features: [] }, "corner-bakery")))).toContain("A plan for corner-bakery"); // text from a server cannot draw on the terminal - expect(await printed(() => planCard({ plan: { name: "Te\u001b[2Jam" }, price: "$1\u0007", features: ["x\u001b[31m"] }, "a\u001b[0m"))).not.toMatch(/\u0007|\u001b\[2J|\u001b\[31m/); + expect(await printed(() => planCard({ plan: { name: "Te\u001b[2Jam" }, price: "$1\u0007", features: ["x\u001b[31m"] }, "a\u001b[0m", "b\u001b[2J"))).not.toMatch(/\u0007|\u001b\[2J|\u001b\[31m/); } finally { process.stdout.columns = cols; } diff --git a/packages/cli/test/suggest.test.ts b/packages/cli/test/suggest.test.ts index 56298e71..2b3a8dc0 100644 --- a/packages/cli/test/suggest.test.ts +++ b/packages/cli/test/suggest.test.ts @@ -185,7 +185,7 @@ describe("genclass suggest: end to end (src/suggest/index.mjs header)", () => { const w = world(env, model); // Free: the first suggestion, and the rest counted, ideas and designs included, with Pro's trial const free = (await printed(async () => suggest({ cwd: dir, url: "https://shop.example", yes: true }, deps(w)))).replace(/\s+/g, " "); - expect(free).toMatch(/ PRO \d+ more suggestions for this app: \d+ from your checks, 2 ideas from the model, 1 website design inspired by sites like yours, each with a prompt for your coding agent\. 3 days free, then \$15 \/ project \/ month\. Cancel any time\. Start: npx genclass upgrade/); + expect(free).toMatch(/ PRO \d+ more suggestions for this app: \d+ from your checks, 2 ideas from the model, 1 website design inspired by sites like yours, each with a prompt for your coding agent\. 3 days free, then \$15 \/ developer \/ month\. Cancel any time\. Start: npx genclass upgrade/); expect(free).not.toContain("A size guide for sleds"); expect(free).not.toContain("patagonia"); expect(runs).toBe(1); diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md index dd223205..89b41c0a 100644 --- a/telemetry-worker/README.md +++ b/telemetry-worker/README.md @@ -532,6 +532,23 @@ npx --yes wrangler@4 d1 execute genclass-dashboard --remote --command " ## Paid plans +**Version 5 (2026-11-13, the owner's calls; what follows below is the history it replaces where they differ).** +Pro (id `team`) is bought for the account again, not per app, and per developer: `per_seat` true, the +subscription's quantity is how many developers (US$15 each a month), and it covers up to 3 apps (`limits.apps`) +whatever the number of developers, oldest first (`src/plans.ts` coverage, accessIn). An app past them is locked +`pro_apps`: its plan page says more apps come with Enterprise, by email (a mailto button, no purchase). A +subscription bought per project under version 4 still covers its own app (never locked) and gives the account +Pro. Free covers one app (`free_projects` past it). Every new app of an account opens its plan page once, a paying +account's too: "Continue with Pro" (`POST /api/billing/projects//continue` writes `plan_chosen`). Pro's +3-day trial (card up front, once per account) starts on the account's first Checkout (`src/billing.ts` trialFor). +**The genclass command is Pro's** (the owner's "let's make the entire CLI paid"): a CLI session of an account with +no plan (paid, trialing, a grant) and no active place in a paying team gets 402 `needs_pro` from every `/api/cli` +route but the overview and the account, which carry `cli` (`src/cli.ts` cliPaid). Nothing is locked for a browser +session, where nothing is sold to the account (no Stripe key, a test key and an account not listed) or with +PLAN_GATES open. Free's list (the owner: "1 app connection, limited dashboard, open-source basics", and "keep them +free"): one app, the totals for 7 days, the open-source runtime and model, the coding-agent loop, self-hosting, the +GitHub App on public repositories, the page check, 100 live fixes a month. + The plans live in [`billing/plans.json`](../billing/plans.json) (version 3, 2026-11-11, the owner: "cut back on the free features"; one thing stays free, finding, seeing and fixing the problems): @@ -1171,7 +1188,7 @@ sign-in, `/api/me`, `/api/account/projects`) and four of its own (`src/cli.ts`; | Route | | |---|---| -| `GET /api/cli/overview` | CLI session or browser session: the account, its plan `{ id, name }`, its apps, `suggestions: { per_day, shown, used_today, model }` and where the GitHub App's install and connect pages are | +| `GET /api/cli/overview` | CLI session or browser session: the account, its plan `{ id, name }`, its apps, `suggestions: { per_day, shown, used_today, model }`, where the GitHub App's install and connect pages are, and `cli`: `{ paid: true }`, or `{ paid: false, plan, price, trial_days, checkout_url }` (the account's checkout, `?plan=team&start=1`) for an account the command is locked for. Every other route below but `/api/cli/account` answers such a CLI session `402 { ok: false, needs_pro: true, error, ...cli }` (version 5: the genclass command is Pro's) | | `GET /api/cli/github?repo=owner/name` | CLI session or browser session: what the GitHub App is doing on that repository (`known`, `installed`, `evidence`, `prs`, `last`, `private`, `needs_plan`, `paused`). GitHub is asked nothing: the answer comes from what GitHub told this account in the last 30 minutes (Connect GitHub, or Continue with GitHub after installing). Without such an answer: `known: false` and `connect_url` | | `POST /api/cli/suggest` | CLI session only. The body is a digest of the checks that failed on an app (`src/suggest.ts` `readDigest`: at most 60 checks, each an id, area, title, weight and at most 3 evidence lines of 160 characters, plus the app's name, framework, address, payment provider and host; at most 64 KB; anything else is dropped). The answer is the plan: `items` in order, each `{ id, why, step, prompt }`, `locked_ids` for the items the account's plan does not show, `source` (`model` or `checks`), `remaining_today`. Since 2026-11-12 (the owner's call): the digest's `app` also has `about` (the site's title and description, 300 characters); the model also writes `ideas` (up to 20, its own, never shown as measured) and `designs` (up to 4, each `{ title, inspired_by, why, prompt }` with a real site the server checks answers over HTTPS, and a complete prompt for a coding agent); the answer is kept per account and app (`suggest_results`, migration 0019, the app keyed by a hash of its address or name). A plan that shows part of it (Free, `suggestions.shown` 1) gets the kept answer again on later runs (`cached_at`, no new model run), its first item, the rest as `locked`/`locked_ids`, and `ideas_locked`/`designs_locked` as counts with `ideas` and `designs` empty; `upgrade_plan` `{ name, price, trial_days }` and `upgrade_url` say where Pro starts. Pro gets everything | | `GET /api/cli/app?id=&range=7d` | CLI session or browser session: one app's dashboard as data, for `genclass dashboard` and the views beside it (`problems`, `analytics`, `events`, `health`, `agent`, `report`, `fix`). It is what `/api/account/projects//stats` answers (same plan rules: the ranges a plan shows, visitor analytics left out without them, `402` when the plan has no dashboard), with the 50 most recent findings and `dashboard_url`. The app is the account's own or one of a team it is in; anything else is `404`. A CLI session reads it only if it was created at or after `CLI_DASHBOARD_FROM` (`src/cli.ts`), when the approval page began to say "show your apps' dashboards"; an older one gets `403` with `reauth: true`, and the command asks the person to sign in again. `/api/account/projects//stats` stays browser only | diff --git a/telemetry-worker/src/auth.ts b/telemetry-worker/src/auth.ts index 75b11311..6252ca84 100644 --- a/telemetry-worker/src/auth.ts +++ b/telemetry-worker/src/auth.ts @@ -82,7 +82,7 @@ import { forgetAccount as forgetAccountGithub, unlinkProject } from "./github/st import { json, mediaType, readCapped } from "./http.js"; import { dashboardPage, ownEndpoint, securityHeaders, setupCommand } from "./pages.js"; import { DEVICE_FAILURES, DEVICE_MS, DUMMY_HASH, FAILURE_WINDOW_MS, FREE_FAILURES, MAX_DELAY_MS, PASSWORD_MAX, delayAfter, hashPassword, needsRehash, passwordProblem, verifyPassword, waitText } from "./password.js"; -import { accountAccess, accountBilling, canBuy, firstPlanWith, FREE, hasAnalytics, hasDashboard, hasProblems, keepDays, PAID_PLANS, pickRange, problemsLock, projectAccess, viewRanges } from "./plans.js"; +import { accountAccess, accountBilling, canBuy, firstPlanWith, FREE, hasAnalytics, hasDashboard, hasProblems, keepDays, PAID_PLANS, pickRange, PLANS_FILE, problemsLock, projectAccess, viewRanges } from "./plans.js"; import { activeOwners, forgetTeam, membershipsOf } from "./team.js"; import { cancelForDeletion } from "./stripe.js"; import { @@ -2147,13 +2147,18 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E } /** - * The answer to a stats request for an app whose plan has no dashboard: a project past the ones Free covers (it waits - * for a plan of its own: `upgrade_url` opens that project's plan page), or a plan file without dashboards. + * The answer to a stats request for an app whose plan has no dashboard: an app past the ones its account's plan covers + * (past Free's: it needs Pro; past Pro's: Enterprise, by email; `upgrade_url` opens the app's plan page, which says + * which), or a plan file without dashboards. */ -export function lockedJson(c: { cfg: AuthConfig; env: Env }, access?: { locked?: string } | null, projectId?: string | null): Response { - if (access?.locked === "free_projects" && projectId) { +export function lockedJson(c: { cfg: AuthConfig; env: Env }, access?: { locked?: string; apps?: number | null } | null, projectId?: string | null): Response { + if ((access?.locked === "free_projects" || access?.locked === "pro_apps") && projectId) { const url = `${c.cfg.origin}/account/billing?project=${encodeURIComponent(projectId)}`; - return json(402, { ok: false, error: `Free covers ${FREE.limits.apps} projects, and this one is past them: it needs a plan of its own; choose it at ${url}`, locked: true, needs_plan: true, upgrade_url: url }); + const covers = (n: number | null | undefined) => (n === 1 ? "one app" : `${n ?? "its"} apps`); + const error = access.locked === "free_projects" + ? `Free covers ${covers(FREE.limits.apps)}, and this one is past it: it needs ${PAID_PLANS[0]?.name ?? "a paid plan"}; start it at ${url}` + : `your plan covers ${covers(access.apps)}, and this one is past them: more apps come with Enterprise (${PLANS_FILE.contact}); see ${url}`; + return json(402, { ok: false, error, locked: true, needs_plan: true, upgrade_url: url }); } const url = `${c.cfg.origin}/account/billing`; return json(402, { ok: false, error: lockedError(c.env), locked: true, upgrade_url: url }); diff --git a/telemetry-worker/src/billing-pages.ts b/telemetry-worker/src/billing-pages.ts index 68b414b9..32dfed47 100644 --- a/telemetry-worker/src/billing-pages.ts +++ b/telemetry-worker/src/billing-pages.ts @@ -203,8 +203,8 @@ function projectsSection(v: BillingView): string { }) .join(""); return `
    -

    Projects

    A plan per project
    -

    Each project has its own plan. Free covers ${FREE_APPS} projects; ${esc(PAID_PLANS[0] ? `${PAID_PLANS[0].name} is ${priceText(PAID_PLANS[0])}` : "")}, everyone on your team included.

    +

    Apps

    What your plan covers
    +

    Free covers ${FREE_APPS === 1 ? "one app" : `${FREE_APPS} apps`}${PAID_PLANS[0] ? `; ${esc(PAID_PLANS[0].name)} covers ${PAID_PLANS[0].limits.apps ? `up to ${PAID_PLANS[0].limits.apps} apps` : "every app"}, ${esc(priceText(PAID_PLANS[0]))}` : ""}. More apps come with Enterprise.

    ${rows}
    ProjectPlan
    `; } @@ -256,21 +256,22 @@ export function projectPlanPage(nonce: string, v: ProjectPlanView): string { const a = v.access; const paid = a.source === "paid"; const name = esc(v.project.name); - // version 5 (2026-11-13): Pro is the account's, 3 apps for each $15; every new app sees this page, a paying account - // included (Continue with Pro), and an app past what the plan covers waits here for more + // version 5 (2026-11-13): Pro is the account's, per developer, for up to 3 apps; every new app sees this page, a + // paying account included (Continue with Pro), and an app past what the plan covers waits here: more apps are + // Enterprise's, by email (a plan sold by units of apps, apps_per_unit, adds them on Stripe instead) const per = offer?.apps_per_unit ?? null; const proFull = a.locked === "pro_apps"; const covered = !a.locked && a.source !== null; const status = a.source === "open" ? `This server gives every app everything in ${esc(a.plan.name)}.` : proFull - ? `Your ${esc(offer?.name ?? "paid")} plan covers ${a.apps ?? 0} apps, and ${name} is one more.${per ? ` Add ${per} more apps for ${esc(priceText(offer!).replace(/ for \d+ apps$/, ""))}.` : ""}` + ? `Your ${esc(offer?.name ?? "paid")} plan covers ${per ? "" : "up to "}${a.apps ?? 0} apps, and ${name} is one more.${per ? ` Add ${per} more apps for ${esc(priceText(offer!).replace(/ for \d+ apps$/, ""))}.` : " More apps come with Enterprise, agreed by email."}` : paid ? v.sub.cancels ? `${name} is on ${esc(a.plan.name)} until ${day(v.sub.periodEnd)}. It is cancelled and will not renew; you can resume it in Manage billing.` : v.sub.status === "past_due" ? `${name} is on ${esc(a.plan.name)}. The last payment did not go through: update your card in Manage billing.` - : `Your ${esc(a.plan.name)} plan covers ${name}${a.apps ? ` (${a.apps} apps on your account)` : ""}.` + : `Your ${esc(a.plan.name)} plan covers ${name}${a.apps ? ` (up to ${a.apps} apps on your account)` : ""}.` : a.source === "comp" ? `Your ${esc(a.plan.name)} plan, set up by GenClass, covers ${name}.` : a.locked @@ -290,8 +291,10 @@ export function projectPlanPage(nonce: string, v: ProjectPlanView): string { const features = (offer?.features ?? []).filter((f) => f.available).map((f) => `
  • ${esc(f.text)}
  • `).join(""); const action = covered ? `

    Your current plan.${paid ? ` Manage billing` : ""}

    ` - : proFull - ? `

    ${esc(priceText(offer!).replace(/ for \d+ apps$/, ""))} more, on your next bill. You choose the quantity on Stripe.

    ` + : proFull && per + ? `

    ${esc(priceText(offer!).replace(/ for \d+ apps$/, ""))} more, on your next bill. You choose the quantity on Stripe.

    ` + : proFull + ? `Email us about Enterprise

    Or remove an app you no longer use, and this one takes its place.

    ` : v.live ? `${v.trialDays ? `

    ${v.trialDays} days free, then ${esc(priceText(offer!))}. Cancel before the trial ends and you pay nothing.

    ` : ""}` : 'Paid plans are not on sale yet.'; @@ -326,7 +329,7 @@ ${notice}${test}

    All your projects and their plans

    -`; +`; const script = ` (function(){ gcDropQuery(["project"]); diff --git a/telemetry-worker/src/billing.ts b/telemetry-worker/src/billing.ts index 97ac6aa8..1f75f76a 100644 --- a/telemetry-worker/src/billing.ts +++ b/telemetry-worker/src/billing.ts @@ -317,7 +317,7 @@ async function chooseFree(c: C, f: Fetch, id: string): Promise { const p = await ownedProject(c, a.account.id, id); if (!p || !p.id) return fail(404, "not found"); const access = await projectAccess(c.db, p, c.env, c.now); - if (access.locked) return fail(409, `Free covers ${FREE.limits.apps ?? 3} projects, and this one is past them: it needs a paid plan`); + if (access.locked) return fail(409, access.locked === "pro_apps" ? "your plan covers no more apps: more apps come with Enterprise" : `Free covers ${FREE.limits.apps === 1 ? "one app" : `${FREE.limits.apps ?? 1} apps`}, and this one is past it: it needs a paid plan`); const at = c.now.toISOString(); await c.db.batch([ c.db.prepare("INSERT INTO plan_feedback (account_id, project_id, reason, note, created) VALUES (?, ?, ?, ?, ?)").bind(a.account.id, p.id, reason[0], note || null, at), diff --git a/telemetry-worker/src/cli.ts b/telemetry-worker/src/cli.ts index 7cf9eba3..6f8f3659 100644 --- a/telemetry-worker/src/cli.ts +++ b/telemetry-worker/src/cli.ts @@ -1,7 +1,8 @@ // The routes the `genclass` command asks (packages/cli/src/api.mjs is the client). Checked before the account routes. // // GET /api/cli/overview the account, its plan, its apps, what suggestions it has left today, and -// for an account on Free the plan it can start (the command's plan step) +// for an account on Free the plan it can start (the command's plan step); +// `cli`: whether the command works for it (it is Pro's) and where Pro starts // GET /api/cli/github?repo=o/n what genclass-dev[bot] is doing on one repository, from the account's own answer // POST /api/cli/suggest the plan for the checks that failed on an app (src/suggest.ts) // GET /api/cli/app?id=&range=7d the app's dashboard as data: what the browser dashboard reads (`genclass dashboard`, @@ -19,6 +20,10 @@ // POST /api/cli/ask a question about an app, answered by the model from its numbers (src/ask.ts) // // Invariants: +// - The genclass command is Pro's (the owner's call, 2026-11-13): a CLI session of an account with neither a plan +// (paid, in its trial, or GenClass's grant) nor an active place in a team that has one gets 402 `needs_pro` from +// every route but the overview and the account (cliPaid). Nothing is locked where nothing is sold to the account, +// with PLAN_GATES open, or for a browser session: the website keeps Free. // - Every route needs a signed-in account: the CLI's session (a bearer token from `genclass login`) or, for the // first three GETs, the browser session. Every other route takes the CLI's session only, so no page can make a // signed-in browser spend an account's runs or change its settings through them. @@ -53,11 +58,42 @@ import { json } from "./http.js"; import { accountAccess, accountBilling, accountProjectsAccess, canBuy, entitled, firstPaidWith, firstPlanWith, FREE, hasAnalytics, hasAsk, hasDashboard, hasProblems, PAID_PLANS, pickRange, priceText, problemsLock, projectAccess, viewRanges, type Access } from "./plans.js"; import { liveFixUsage } from "./usage.js"; import { appKey, askModel, byWeight, claimRun, hasModel, keepAnswer, lastAnswer, MAX_SUGGEST_BYTES, readDigest, suggestionLimits, usedToday, type PlanItem, type SuggestAnswer } from "./suggest.js"; -import { createInvite, isTeamError, membershipsOf, teamOf } from "./team.js"; +import { activeOwners, createInvite, isTeamError, membershipsOf, teamOf } from "./team.js"; /** A repository's full name as GitHub writes one. */ const FULL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/(?!\.{1,2}$)[A-Za-z0-9._-]{1,100}$/; +// ------------------------------------------------------------------------- the genclass command is Pro's + +/** + * The genclass command works for this session (the owner's call, 2026-11-13: "let's make the entire CLI paid"): a + * browser session always; a CLI session when the account has a plan of its own (paid, in its trial, or GenClass's + * grant) or an active place in a team that has one, and wherever nothing is sold to the account (no plan on sale, no + * Stripe key, a test key and an account not on its list). PLAN_GATES open gives every account the grant. + */ +export async function cliPaid(c: C, a: Auth): Promise { + if (a.via !== "bearer" || !PAID_PLANS.length || !canBuy(c.env, a.account)) return true; + if ((await accountAccess(c.db, a.account.id, c.env, c.now)).source !== null) return true; + for (const owner of await activeOwners(c.db, c.env, a.account.id, c.now)) { + if ((await accountAccess(c.db, owner, c.env, c.now)).source !== null) return true; + } + return false; +} + +/** + * What the command says about itself (`cli` in the overview and the account): `paid`, and for an account without + * Pro the plan, its price, the trial it has left and the page that opens its checkout at once (the account's, so a + * new account with no app goes straight to Stripe). + */ +async function cliJson(c: C, a: Auth): Promise<{ paid: true } | { paid: false; plan: string; price: string; trial_days: number | null; checkout_url: string }> { + if (await cliPaid(c, a)) return { paid: true }; + const plan = PAID_PLANS[0]!; + return { paid: false, plan: plan.name, price: priceText(plan), trial_days: (await trialFor(c.db, a.account.id, plan)) || null, checkout_url: `${c.cfg.origin}/account/billing?plan=${plan.id}&start=1` }; +} + +/** The routes that are Pro's for a CLI session: all but the overview and the account. */ +const PRO_ROUTE_RE = /^\/api\/cli\/(?:github|suggest|app|team\/invite|connections(?:\/[a-z]+(?:\/test)?)?|events|funnels|link|ask)$/; + async function overview(c: C): Promise { const a = await authenticate(c); if (!a) return fail(401, "not signed in"); @@ -72,6 +108,7 @@ async function overview(c: C): Promise { github: { app: gh !== null, install_url: gh ? `${c.cfg.origin}/github/install` : null, connect_url: gh?.clientId ? `${c.cfg.origin}/github/connect` : null }, billing_url: `${c.cfg.origin}/account/billing`, offer: await offerFor(c, a.account, access, await accountProjectsAccess(c.db, a.account.id, c.env, c.now)), + cli: await cliJson(c, a), }); } @@ -268,6 +305,7 @@ async function account(c: C): Promise { }, billing_url: `${c.cfg.origin}/account/billing`, offer: await offerFor(c, a.account, access, await accountProjectsAccess(c.db, a.account.id, c.env, c.now)), + cli: await cliJson(c, a), }); } @@ -363,6 +401,14 @@ export async function routeCli(request: Request, url: URL, path: string, env: En const m = request.method; const get = m === "GET" || m === "HEAD"; if (path === "/api/cli/overview") return get ? overview(c) : fail(405, "method not allowed"); + // every other route is Pro's for a CLI session (cliPaid); the account's stays open, to say where Pro starts + if (PRO_ROUTE_RE.test(path)) { + const who = await authenticate(c); + if (who) { + const cli = await cliJson(c, who); + if (!cli.paid) return json(402, { ok: false, error: `the genclass command comes with ${cli.plan}${cli.trial_days ? `, ${cli.trial_days} days free` : ""}: ${cli.checkout_url}`, needs_pro: true, ...cli }); + } + } if (path === "/api/cli/github") return get ? github(c) : fail(405, "method not allowed"); if (path === "/api/cli/suggest") return m === "POST" ? suggest(c) : fail(405, "method not allowed"); if (path === "/api/cli/app") return get ? app(c) : fail(405, "method not allowed"); diff --git a/telemetry-worker/src/plans.ts b/telemetry-worker/src/plans.ts index 9df23c6b..51ee940e 100644 --- a/telemetry-worker/src/plans.ts +++ b/telemetry-worker/src/plans.ts @@ -426,6 +426,8 @@ export interface Coverage { access: Access; cap: number | null; rows: ProjectPlanRow[]; + /** The tokens of the apps with a subscription of their own (bought per project under version 4): always covered. */ + ownPaid?: Set; } /** @@ -439,20 +441,26 @@ export async function coverage(db: D1Like, accountId: string, mode: StripeMode | const rows = (await db.prepare(`SELECT ${PROJECT_PLAN_COLUMNS} FROM projects WHERE account_id = ? ORDER BY created, rowid`).bind(accountId).all()).results ?? []; let best = accessOf(acct, mode, now); let units = best.source === "paid" ? Math.max(1, cleanSeats(acct?.seats) ?? 1) : 0; + const ownPaid = new Set(); for (const r of rows) { const own = projectOwnAccess(r, mode, now); - if (own.source === "paid") units++; + if (own.source === "paid") { + units++; + ownPaid.add(r.token); + } best = better(best, own); } const per = best.plan.apps_per_unit; const cap = best.source === "comp" || best.source === "open" ? null : best.source === "paid" ? (per ? per * Math.max(1, units) : (best.plan.limits.apps ?? null)) : (FREE.limits.apps ?? null); - return { access: { ...best, apps: cap }, cap, rows }; + return { access: { ...best, apps: cap }, cap, rows, ownPaid }; } /** One app's access under its account's coverage: the plan when the app is among the first `cap`, else locked. */ export function accessIn(cov: Coverage, project: { token?: string; id?: string | null }): Access { const i = cov.rows.findIndex((r) => (project.token ? r.token === project.token : r.id === project.id)); if (i < 0 || cov.cap === null || i < cov.cap) return cov.access; + // an app still paying for itself (a version 4 subscription) is never locked, wherever it is in the list + if (cov.ownPaid?.has(cov.rows[i]!.token)) return cov.access; return { ...NO_ACCESS, apps: cov.cap, locked: cov.access.source === null ? "free_projects" : "pro_apps" }; } diff --git a/telemetry-worker/test/billing.test.ts b/telemetry-worker/test/billing.test.ts index 7fcee9b4..df319c23 100644 --- a/telemetry-worker/test/billing.test.ts +++ b/telemetry-worker/test/billing.test.ts @@ -102,7 +102,8 @@ describe("POST /api/billing/checkout and /api/billing/portal", () => { const s = fakeStripe(); const r = await call(env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie, fetch: s.fetch }); expect(r.status).toBe(200); - expect(await r.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000001" }); + // the fixture's Pro has no trial_days: no days free + expect(await r.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000001", trial_days: null }); expect(s.calls.map((x) => `${x.method} ${x.url.pathname}`)).toEqual(["POST /v1/customers", "GET /v1/prices", "POST /v1/checkout/sessions"]); const [customer, prices, session] = s.calls; expect(prices!.url.searchParams.get("lookup_keys[0]")).toBe("genclass_pro_monthly"); @@ -140,7 +141,7 @@ describe("POST /api/billing/checkout and /api/billing/portal", () => { // the unpaid Pro page is closed, and the price comes from Custom's own lookup key s.calls.length = 0; const second = await call(env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie, fetch: s.fetch }); - expect(await second.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000002" }); + expect(await second.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000002", trial_days: null }); // the stored customer is checked first: it must be the account's own GenClass customer expect(s.calls.map((x) => `${x.method} ${x.url.pathname}`)).toEqual([ "GET /v1/customers/cus_test0001", @@ -163,7 +164,7 @@ describe("POST /api/billing/checkout and /api/billing/portal", () => { // a second click on Custom (or a second tab) gets the same open page, not a second one s.calls.length = 0; const third = await call(env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie, fetch: s.fetch }); - expect(await third.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000002" }); + expect(await third.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000002", trial_days: null }); expect(s.calls.map((x) => `${x.method} ${x.url.pathname}`)).toEqual(["GET /v1/customers/cus_test0001", "GET /v1/subscriptions", "GET /v1/checkout/sessions"]); }); @@ -1526,6 +1527,9 @@ describe("plans: the Free plan's limits (apps, dashboard ranges)", () => { const locked = await call(env, "GET", `/api/account/projects/${app.id}/stats?range=30d`, { cookie }); expect(locked.status).toBe(402); expect(await locked.json()).toMatchObject({ ok: false, locked: true, upgrade_url: "https://genclass.dev/account/billing" }); + // with a plan on sale, the app's page opens its plan page until a plan is chosen there (plans.json version 5) + expect((await call(env, "GET", `/account/apps/${app.id}`, { cookie })).headers.get("location")).toBe(`/account/billing?project=${app.id}`); + sql.db.prepare("UPDATE projects SET plan_chosen = ? WHERE id = ?").run(now().toISOString(), app.id); const page = await (await call(env, "GET", `/account/apps/${app.id}`, { cookie })).text(); expect(page).toContain("This app's plan has no dashboard."); expect(page).not.toContain('data-r="7d"'); @@ -1582,6 +1586,8 @@ describe("GET /account/billing", () => { // the sidebars link to it expect(await (await call(on.env, "GET", "/account", { cookie: b.cookie })).text()).toContain(''); const app = await createApp(on.env, b.cookie); + // its plan chosen first (until then the app's page opens its plan page) + on.sql.db.prepare("UPDATE projects SET plan_chosen = ? WHERE id = ?").run(now().toISOString(), app.id); expect(await (await call(on.env, "GET", `/account/apps/${app.id}`, { cookie: b.cookie })).text()).toContain(''); }); diff --git a/telemetry-worker/test/cli-account.test.ts b/telemetry-worker/test/cli-account.test.ts index 68e25581..10e18fd3 100644 --- a/telemetry-worker/test/cli-account.test.ts +++ b/telemetry-worker/test/cli-account.test.ts @@ -31,10 +31,14 @@ async function cliUser(env: E, email = "ada@example.com") { /** A paid Team (named Pro since 2026-11-12) account with `seats` seats (as the webhook leaves it). */ const team = (sql: ReturnType["sql"], accountId: string, seats = 3) => setPlan(sql, accountId, "team", { stripe_mode: "live", seats, subscription_id: "sub_t000000001", subscription_status: "active", current_period_end: "2026-12-13T12:00:00.000Z" }); -/** One app on Pro (plans.json id team), paid for on its own as the webhook leaves it (plans per project, version 4). */ +/** One app paid for on its own as the version-4 webhook left it (plans per project); since version 5 it gives the account Pro. */ const pro = (sql: ReturnType["sql"], projectId: string) => sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_id = ?, subscription_status = 'active' WHERE id = ?").run(`sub_${projectId.slice(0, 10)}`, projectId); const LIVE = { STRIPE_SECRET_KEY: "sk_live_fake", INTEGRATIONS_KEY: SEAL }; +/** A server that sells nothing (no Stripe key): the genclass command is not locked there, so Free meets its plan's own rules. */ +const NOT_SOLD = { INTEGRATIONS_KEY: SEAL }; +/** The 402 a Free CLI session gets where Pro is on sale (src/cli.ts cliPaid, the owner's call of 2026-11-13). */ +const NEEDS_PRO = { ok: false, needs_pro: true, paid: false, plan: "Pro", checkout_url: "https://genclass.dev/account/billing?plan=team&start=1" }; const get = (env: E, path: string, bearer: string) => call(env, "GET", path, { bearer, origin: null }); const send = (env: E, method: string, path: string, bearer: string, body?: unknown, fetch?: Fetch) => call(env, method, path, { bearer, origin: null, body, fetch }); const json = async (r: Promise | Response) => (await (await r).json()) as any; @@ -102,8 +106,8 @@ describe("genclass command: GET /api/cli/account and POST /api/cli/team/invite", const j = await json(get(env, "/api/cli/account", me.bearer)); expect(j.plan).toMatchObject({ id: "free", name: "Free", price: null }); expect(j.access).toBeNull(); - // Free is shown one item of genclass suggest's answer since 2026-11-12 - expect(j.limits).toEqual({ apps: 3, history_days: 7, seats: 1, suggestions_per_day: 3, suggestions_shown: 1 }); + // Free is shown one item of genclass suggest's answer since 2026-11-12, and covers one app since plans.json version 5 + expect(j.limits).toEqual({ apps: 1, history_days: 7, seats: 1, suggestions_per_day: 3, suggestions_shown: 1 }); expect(j.used).toEqual({ apps: 1, suggestions_today: 0, seats: 1 }); expect(j.includes).toEqual({ analytics: false, forwarding: false, export: false, private_repositories: false }); expect(j.team).toEqual({ seats: 1, used: 1, members: [], invites: [], memberships: [] }); @@ -113,9 +117,17 @@ describe("genclass command: GET /api/cli/account and POST /api/cli/team/invite", expect(j.billing_url).toBe("https://genclass.dev/account/billing"); // nothing of how the account signs in or pays expect(JSON.stringify(j)).not.toMatch(/stripe|customer|password|passkey/i); + // the genclass command is Pro's where Pro is sold: the account says so, and every other route answers 402 + expect(j.cli).toEqual({ paid: false, plan: "Pro", price: "$15 / developer / month", trial_days: 3, checkout_url: "https://genclass.dev/account/billing?plan=team&start=1" }); const no = await send(env, "POST", "/api/cli/team/invite", me.bearer, {}); - expect(no.status).toBe(403); - expect(await no.json()).toMatchObject({ ok: false, upgrade_url: "https://genclass.dev/account/billing" }); + expect(no.status).toBe(402); + expect(await no.json()).toMatchObject(NEEDS_PRO); + // where nothing is sold the command is open, and Free's team has no seat to invite to + const free = setup(NOT_SOLD); + const you = await cliUser(free.env); + const none = await send(free.env, "POST", "/api/cli/team/invite", you.bearer, {}); + expect(none.status).toBe(403); + expect(await none.json()).toMatchObject({ ok: false, upgrade_url: "https://genclass.dev/account/billing" }); }); it("Pro (id team): the price, the subscription's state, the seats, and an invite link that is shown once", async () => { @@ -123,9 +135,9 @@ describe("genclass command: GET /api/cli/account and POST /api/cli/team/invite", const me = await cliUser(env); team(sql, me.account.id, 3); const j = await json(get(env, "/api/cli/account", me.bearer)); - expect(j.plan).toMatchObject({ id: "team", name: "Pro", price: "$15 / project / month" }); - // Team is per project since plans.json version 4: no seat count, the whole team is in - expect([j.access, j.limits.seats, j.limits.apps, j.limits.history_days]).toEqual(["paid", null, null, 90]); + expect(j.plan).toMatchObject({ id: "team", name: "Pro", price: "$15 / developer / month" }); + // Pro is per developer since plans.json version 5: the seats paid for, and up to 3 apps for the account + expect([j.access, j.limits.seats, j.limits.apps, j.limits.history_days]).toEqual(["paid", 3, 3, 90]); expect(j.includes).toEqual({ analytics: true, forwarding: true, export: true, private_repositories: true }); expect(j.subscription).toEqual({ status: "active", current_period_end: "2026-12-13T12:00:00.000Z", cancel_at_period_end: false }); expect(j.offer).toBeNull(); @@ -141,7 +153,15 @@ describe("genclass command: GET /api/cli/account and POST /api/cli/team/invite", describe("genclass command: /api/cli/connections (forwarding, src/billing.ts integrationsFor)", () => { it("Free sees that forwarding is not in its plan and cannot set a destination up", async () => { - const { env } = setup(LIVE); + // where Pro is sold, a Free terminal does not reach the route at all (the genclass command is Pro's) + const selling = setup(LIVE); + const ada = await cliUser(selling.env); + const shop = await createApp(selling.env, ada.cookie, "Shop"); + const locked = await get(selling.env, `/api/cli/connections?id=${shop.id}`, ada.bearer); + expect(locked.status).toBe(402); + expect(await locked.json()).toMatchObject(NEEDS_PRO); + // where nothing is sold: the plan's own rule, the browser's + const { env } = setup(NOT_SOLD); const me = await cliUser(env); const app = await createApp(env, me.cookie, "Shop"); expect(await json(get(env, `/api/cli/connections?id=${app.id}`, me.bearer))).toEqual({ @@ -185,8 +205,9 @@ describe("genclass command: /api/cli/connections (forwarding, src/billing.ts int expect((await json(get(env, `/api/cli/connections?${at}`, me.bearer))).integrations[0].enabled).toBe(false); expect((await send(env, "DELETE", `/api/cli/connections/slack?${at}`, me.bearer)).status).toBe(204); expect((await json(get(env, `/api/cli/connections?${at}`, me.bearer))).integrations).toEqual([]); - // another account's terminal, and a member's, get nothing of it + // another account's terminal (one with a plan, so the command works for it) gets nothing of it const other = await cliUser(env, "bob@example.com"); + team(sql, other.account.id); expect((await get(env, `/api/cli/connections?${at}`, other.bearer)).status).toBe(404); expect((await send(env, "PUT", `/api/cli/connections/slack?${at}`, other.bearer, { url: SLACK })).status).toBe(404); }); @@ -198,9 +219,17 @@ describe("genclass command: GET /api/cli/events (the export, src/billing.ts expo const me = await cliUser(env); const app = await createApp(env, me.cookie, "Shop"); await sendBatch(env, app.token, [{ t: "session", host: "shop.example", route: "/" }, ...detection("stale", "/search")]); + // a Free terminal where Pro is sold: the command is Pro's const no = await get(env, `/api/cli/events?id=${app.id}`, me.bearer); - expect(no.status).toBe(403); - expect(await no.json()).toMatchObject({ upgrade_url: "https://genclass.dev/account/billing" }); + expect(no.status).toBe(402); + expect(await no.json()).toMatchObject(NEEDS_PRO); + // where nothing is sold, Free is told which plan has the export + const free = setup(NOT_SOLD); + const you = await cliUser(free.env); + const yours = await createApp(free.env, you.cookie, "Shop"); + const none = await get(free.env, `/api/cli/events?id=${yours.id}`, you.bearer); + expect(none.status).toBe(403); + expect(await none.json()).toMatchObject({ upgrade_url: "https://genclass.dev/account/billing" }); // batches are kept for the export from the moment the app's plan has it team(sql, me.account.id); resetOwnerCache(); @@ -226,9 +255,17 @@ describe("genclass command: PUT /api/cli/funnels and POST /api/cli/link", () => const me = await cliUser(env); const app = await createApp(env, me.cookie, "Shop"); const body = { funnels: [{ name: "Checkout", steps: ["/cart", "/checkout", "/thanks"] }] }; + // a Free terminal where Pro is sold: the command is Pro's const no = await send(env, "PUT", `/api/cli/funnels?id=${app.id}`, me.bearer, body); - expect(no.status).toBe(403); - expect(await no.json()).toMatchObject({ upgrade_url: "https://genclass.dev/account/billing" }); + expect(no.status).toBe(402); + expect(await no.json()).toMatchObject(NEEDS_PRO); + // where nothing is sold, Free is told funnels need visitor analytics' plan + const free = setup(NOT_SOLD); + const you = await cliUser(free.env); + const yours = await createApp(free.env, you.cookie, "Shop"); + const none = await send(free.env, "PUT", `/api/cli/funnels?id=${yours.id}`, you.bearer, body); + expect(none.status).toBe(403); + expect(await none.json()).toMatchObject({ upgrade_url: "https://genclass.dev/account/billing" }); team(sql, me.account.id); expect(await json(send(env, "PUT", `/api/cli/funnels?id=${app.id}`, me.bearer, body))).toEqual({ ok: true, funnels: body.funnels }); expect((await json(get(env, `/api/cli/app?id=${app.id}`, me.bearer))).funnels).toEqual([{ name: "Checkout", steps: ["/cart", "/checkout", "/thanks"], counts: [0, 0, 0] }]); @@ -272,7 +309,7 @@ describe("genclass command: POST /api/cli/ask (src/ask.ts)", () => { plan: "Pro", upgrade_url: `https://genclass.dev/account/billing?project=${app.id}`, trial_days: 3, - price: "$15 / project / month", + price: "$15 / developer / month", }); expect(model.calls).toEqual([]); expect(sql.rows("SELECT * FROM suggest_usage")).toEqual([]); @@ -280,15 +317,21 @@ describe("genclass command: POST /api/cli/ask (src/ask.ts)", () => { expect((await send(env, "POST", "/api/cli/ask", me.bearer, { id: app.id, question: 7 }, model.fetch)).status).toBe(400); const other = await cliUser(env, "bob@example.com"); expect((await send(env, "POST", "/api/cli/ask", other.bearer, { id: app.id, question: "How is their app?" }, model.fetch)).status).toBe(404); - // the app on Pro: the model answers; the plan is the app's, so the account's other app on Free stays locked + // the app on Pro (a version-4 subscription of its own gives the account Pro, which covers up to 3 apps): the model + // answers for it and for the account's next app; a 4th app is past Pro's 3 and stays locked const blog = await createApp(env, me.cookie, "Blog"); + await createApp(env, me.cookie, "Docs"); + const extra = await createApp(env, me.cookie, "Extra"); pro(sql, app.id); expect(await json(send(env, "POST", "/api/cli/ask", me.bearer, { id: app.id, question: "How is my app?" }, model.fetch))).toMatchObject({ ok: true, answer: "An answer." }); - expect((await send(env, "POST", "/api/cli/ask", me.bearer, { id: blog.id, question: "How is my app?" }, model.fetch)).status).toBe(403); - expect(model.calls).toHaveLength(1); + expect(await json(send(env, "POST", "/api/cli/ask", me.bearer, { id: blog.id, question: "How is my app?" }, model.fetch))).toMatchObject({ ok: true, answer: "An answer." }); + const past = await send(env, "POST", "/api/cli/ask", me.bearer, { id: extra.id, question: "How is my app?" }, model.fetch); + expect(past.status).toBe(402); + expect(await past.json()).toMatchObject({ ok: false, locked: true }); + expect(model.calls).toHaveLength(2); // GenClass's grant of Enterprise covers every app of the account sql.db.prepare("UPDATE accounts SET comp_until = '9999-12-31T00:00:00.000Z' WHERE id = ?").run(me.account.id); - expect((await send(env, "POST", "/api/cli/ask", me.bearer, { id: blog.id, question: "How is my app?" }, model.fetch)).status).toBe(200); + expect((await send(env, "POST", "/api/cli/ask", me.bearer, { id: extra.id, question: "How is my app?" }, model.fetch)).status).toBe(200); }); it("the model answers from the app's numbers; the request holds the question and the digest, never a key or who asked", async () => { @@ -404,3 +447,152 @@ describe("genclass command: POST /api/cli/ask (src/ask.ts)", () => { expect(system).toContain("You have not seen their code"); }); }); + +describe("the genclass command is Pro's (src/cli.ts cliPaid; the owner's call of 2026-11-13, 'make the entire CLI paid')", () => { + const CHECKOUT = "https://genclass.dev/account/billing?plan=team&start=1"; + /** What every gated route answers a Free CLI session where Pro is on sale. */ + const LOCKED = { + ok: false, + error: `the genclass command comes with Pro, 3 days free: ${CHECKOUT}`, + needs_pro: true, + paid: false, + plan: "Pro", + price: "$15 / developer / month", + trial_days: 3, + checkout_url: CHECKOUT, + }; + /** A Free account with an app and a CLI session, on a server with these keys. */ + const freeUser = async (over: Record = LIVE) => { + const t = setup(over); + const me = await cliUser(t.env); + const app = await createApp(t.env, me.cookie, "Shop"); + return { ...t, me, app, appStatus: async (bearer = me.bearer) => (await get(t.env, `/api/cli/app?id=${app.id}`, bearer)).status }; + }; + + it("a Free CLI session gets 402 needs_pro on /api/cli/app, with the plan, its price, the trial and where Pro starts", async () => { + const { env, me, app } = await freeUser(); + const r = await get(env, `/api/cli/app?id=${app.id}`, me.bearer); + expect(r.status).toBe(402); + expect(await r.json()).toEqual(LOCKED); + }); + + it("every route but the overview and the account is gated, whatever the method; those two say what the command needs", async () => { + const { env, sql: db, me, app } = await freeUser(); + const at = `id=${app.id}`; + const routes: [string, string, unknown?][] = [ + ["GET", "/api/cli/github?repo=ada/shop"], + ["POST", "/api/cli/suggest", {}], + ["GET", `/api/cli/app?${at}`], + ["POST", "/api/cli/team/invite", {}], + ["GET", `/api/cli/connections?${at}`], + ["PUT", `/api/cli/connections/slack?${at}`, { url: SLACK }], + ["POST", `/api/cli/connections/slack/test?${at}`, {}], + ["GET", `/api/cli/events?${at}`], + ["PUT", `/api/cli/funnels?${at}`, { funnels: [] }], + ["POST", `/api/cli/link?${at}`, {}], + ["POST", "/api/cli/ask", { id: app.id, question: "How is my app?" }], + // a wrong method is still the command's + ["DELETE", `/api/cli/app?${at}`], + ]; + for (const [method, path, body] of routes) { + const r = await send(env, method, path, me.bearer, body); + expect([method, path, r.status]).toEqual([method, path, 402]); + expect(await r.json()).toMatchObject({ needs_pro: true, paid: false }); + } + // nothing was made or changed behind them + expect(db.rows("SELECT (SELECT COUNT(*) FROM team_invites) AS invites, (SELECT COUNT(*) FROM integrations) AS integrations, (SELECT COUNT(*) FROM suggest_usage) AS runs")).toEqual([{ invites: 0, integrations: 0, runs: 0 }]); + // the overview and the account answer, with what the command needs and where Pro starts + const { checkout_url, plan, price, trial_days } = LOCKED; + for (const path of ["/api/cli/overview", "/api/cli/account"]) { + const r = await get(env, path, me.bearer); + expect([path, r.status]).toEqual([path, 200]); + expect((await r.json() as any).cli).toEqual({ paid: false, plan, price, trial_days, checkout_url }); + } + // a signed-out terminal is still told to sign in + expect((await call(env, "GET", `/api/cli/app?${at}`, { origin: null })).status).toBe(401); + }); + + it("the trial is offered once: an account that had a subscription is told the price with no days free", async () => { + const { env, sql: db, me } = await freeUser(); + setPlan(db, me.account.id, "free", { subscription_id: "sub_old0000001", subscription_status: "canceled" }); + const r = await get(env, "/api/cli/overview", me.bearer); + expect((await r.json() as any).cli).toEqual({ paid: false, plan: "Pro", price: "$15 / developer / month", trial_days: null, checkout_url: CHECKOUT }); + const no = await send(env, "POST", "/api/cli/suggest", me.bearer, {}); + expect(await no.json()).toMatchObject({ error: `the genclass command comes with Pro: ${CHECKOUT}`, trial_days: null }); + }); + + it("the same account's browser session is never gated: the website keeps Free", async () => { + const { env, me, app } = await freeUser(); + const r = await call(env, "GET", `/api/cli/app?id=${app.id}`, { cookie: me.cookie, origin: null }); + expect(r.status).not.toBe(402); + expect(r.status).toBe(200); + expect((await call(env, "GET", "/api/cli/overview", { cookie: me.cookie, origin: null })).status).toBe(200); + expect((await json(call(env, "GET", "/api/cli/overview", { cookie: me.cookie, origin: null }))).cli).toEqual({ paid: true }); + }); + + it("Pro (active or in its trial), a version-4 project subscription and GenClass's grant open the command", async () => { + const { env, sql: db, me, app, appStatus } = await freeUser(); + expect(await appStatus()).toBe(402); + team(db, me.account.id); + expect(await appStatus()).toBe(200); + expect((await json(get(env, "/api/cli/overview", me.bearer))).cli).toEqual({ paid: true }); + expect((await json(get(env, "/api/cli/account", me.bearer))).cli).toEqual({ paid: true }); + setPlan(db, me.account.id, "team", { subscription_status: "trialing" }); + expect(await appStatus()).toBe(200); + // a version-4 subscription of one app + setPlan(db, me.account.id, "free", { stripe_mode: null, seats: null, subscription_id: null, subscription_status: null }); + expect(await appStatus()).toBe(402); + pro(db, app.id); + expect(await appStatus()).toBe(200); + db.db.prepare("UPDATE projects SET plan = NULL, subscription_status = 'canceled' WHERE id = ?").run(app.id); + expect(await appStatus()).toBe(402); + // GenClass's grant, the account's or one copied onto an app + setPlan(db, me.account.id, "free", { comp_until: "9999-12-31T00:00:00.000Z" }); + expect(await appStatus()).toBe(200); + setPlan(db, me.account.id, "free", { comp_until: null }); + db.db.prepare("UPDATE projects SET comp_until = '9999-12-31T00:00:00.000Z', comp_plan = 'team' WHERE id = ?").run(app.id); + expect(await appStatus()).toBe(200); + // a grant that has ended does not + db.db.prepare("UPDATE projects SET comp_until = '2026-01-01T00:00:00.000Z' WHERE id = ?").run(app.id); + expect(await appStatus()).toBe(402); + }); + + it("an active member of a paying owner's team is not gated; one past the owner's seats is", async () => { + const t = setup(LIVE); + const owner = await cliUser(t.env, "ada@example.com"); + team(t.sql, owner.account.id, 2); + const shop = await createApp(t.env, owner.cookie, "Shop"); + const bob = await cliUser(t.env, "bob@example.com"); + const mine = await createApp(t.env, bob.cookie, "Mine"); + expect((await get(t.env, `/api/cli/app?id=${mine.id}`, bob.bearer)).status).toBe(402); + const inv = (await json(call(t.env, "POST", "/api/team/invites", { body: {}, cookie: owner.cookie }))) as { url: string }; + expect((await call(t.env, "POST", "/api/team/join", { body: { code: inv.url.split("/").pop() }, cookie: bob.cookie })).status).toBe(200); + // bob's own account is still Free, and the command works for him: his own app and the owner's + expect((await json(get(t.env, "/api/cli/account", bob.bearer))).plan).toMatchObject({ id: "free" }); + expect((await get(t.env, `/api/cli/app?id=${mine.id}`, bob.bearer)).status).toBe(200); + expect((await get(t.env, `/api/cli/app?id=${shop.id}`, bob.bearer)).status).toBe(200); + expect((await json(get(t.env, "/api/cli/overview", bob.bearer))).cli).toEqual({ paid: true }); + // the owner down to one seat: bob is past it, and so gated again + team(t.sql, owner.account.id, 1); + expect((await get(t.env, `/api/cli/app?id=${mine.id}`, bob.bearer)).status).toBe(402); + // the owner on Free: nothing to share + team(t.sql, owner.account.id, 2); + expect((await get(t.env, `/api/cli/app?id=${mine.id}`, bob.bearer)).status).toBe(200); + setPlan(t.sql, owner.account.id, "free", { stripe_mode: null, subscription_status: "canceled" }); + expect((await get(t.env, `/api/cli/app?id=${mine.id}`, bob.bearer)).status).toBe(402); + }); + + it("nothing is gated where nothing is sold to the account: no Stripe key, a test key it is not listed for, PLAN_GATES open", async () => { + const none = await freeUser({ INTEGRATIONS_KEY: SEAL }); + expect(await none.appStatus()).toBe(200); + expect((await json(get(none.env, "/api/cli/overview", none.me.bearer))).cli).toEqual({ paid: true }); + const test = await freeUser({ STRIPE_SECRET_KEY: "sk_test_fake" }); + expect(await test.appStatus()).toBe(200); + // the test key sells to the accounts on its list: there the command is Pro's + const listed = await freeUser({ STRIPE_SECRET_KEY: "sk_test_fake", STRIPE_TEST_ACCOUNTS: "ada@example.com" }); + expect(await listed.appStatus()).toBe(402); + const open = await freeUser({ ...LIVE, PLAN_GATES: "open" }); + expect(await open.appStatus()).toBe(200); + expect((await json(get(open.env, "/api/cli/account", open.me.bearer))).cli).toEqual({ paid: true }); + }); +}); diff --git a/telemetry-worker/test/cli.test.ts b/telemetry-worker/test/cli.test.ts index 4c5b749d..e8c98a94 100644 --- a/telemetry-worker/test/cli.test.ts +++ b/telemetry-worker/test/cli.test.ts @@ -69,20 +69,23 @@ describe("genclass command: GET /api/cli/overview (src/cli.ts)", () => { expect(offered.offer).toEqual({ // the plan id stays team; its name is Pro since 2026-11-12 plan: { id: "team", name: "Pro" }, - price: "$15 / project / month", + price: "$15 / developer / month", summary: teamPlan.summary, features: teamPlan.features.filter((f) => f.available && !/^Everything in /.test(f.text)).map((f) => f.text).slice(0, 12), - // plans are per project: the one app on Free has its own plan page + // Pro is the account's (plans.json version 5), bought on the plan page of its first app on Free checkout_url: `https://genclass.dev/account/billing?project=${app.id}&from=cli`, // an account that has never had a subscription starts with Pro's 3 days free (src/billing.ts trialFor) trial_days: 3, - free: { name: "Free", apps: 3, history_days: 7, suggestions_shown: 1, live_fixes: 100 }, + // Pro is per developer since version 5, not sold by units of apps + apps_per_unit: null, + // Free covers one app since version 5 + free: { name: "Free", apps: 1, history_days: 7, suggestions_shown: 1, live_fixes: 100 }, }); // each app says its plan, whether Free was chosen for it, and its plan page expect(offered.projects[0]).toMatchObject({ plan: { id: "free", name: "Free", source: null }, needs_plan: false, plan_chosen: false, plan_url: `https://genclass.dev/account/billing?project=${app.id}` }); sql.db.prepare("UPDATE projects SET plan_chosen = '2026-11-12T00:00:00.000Z' WHERE id = ?").run(app.id); expect(((await (await call(selling, "GET", "/api/cli/overview", { bearer: me.bearer, origin: null })).json()) as any).projects[0].plan_chosen).toBe(true); - // the app on Team: nothing left to offer (the account has no other app on Free) + // the app on a subscription of its own (version 4: it gives the account Pro): nothing left to offer sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_id = 'sub_x', subscription_status = 'active' WHERE id = ?").run(app.id); expect(((await (await call(selling, "GET", "/api/cli/overview", { bearer: me.bearer, origin: null })).json()) as any).offer).toBeNull(); // its subscription ends: the app is on Free again and offered Pro, without a second trial (one per account) @@ -104,11 +107,16 @@ describe("genclass command: GET /api/cli/overview (src/cli.ts)", () => { const team = (await (await call({ ...env, OPENAI_API_KEY: KEY }, "GET", "/api/cli/overview", { bearer: me.bearer, origin: null })).json()) as any; expect(team.plan).toEqual({ id: "team", name: "Pro" }); expect(team.suggestions).toEqual({ per_day: 30, shown: null, used_today: 0, model: true }); - // an account whose apps all have a plan (here GenClass's grant, which is per project for a plan sold per project: - // migration 0016 copied it onto the apps that existed) is offered none, whatever the server sells - expect(((await (await call({ ...selling, OPENAI_API_KEY: KEY }, "GET", "/api/cli/overview", { bearer: me.bearer, origin: null })).json()) as any).offer.checkout_url).toBe(`https://genclass.dev/account/billing?project=${app.id}&from=cli`); + // an account with GenClass's grant is offered none, whatever the server sells: since version 5 a grant covers + // every app of the account, the account's own or one copied onto an app (migration 0016) + const offerNow = async () => ((await (await call({ ...selling, OPENAI_API_KEY: KEY }, "GET", "/api/cli/overview", { bearer: me.bearer, origin: null })).json()) as any); + const granted = await offerNow(); + expect(granted.offer).toBeNull(); + expect(granted.projects[0]).toMatchObject({ plan: { id: "team", source: "comp" }, needs_plan: false }); + setPlan(sql, me.account.id, "free", { comp_until: null, comp_plan: null }); + expect((await offerNow()).offer.checkout_url).toBe(`https://genclass.dev/account/billing?project=${app.id}&from=cli`); sql.db.prepare("UPDATE projects SET comp_until = ?, comp_plan = 'team' WHERE id = ?").run(FUTURE(), app.id); - expect(((await (await call({ ...selling, OPENAI_API_KEY: KEY }, "GET", "/api/cli/overview", { bearer: me.bearer, origin: null })).json()) as any).offer).toBeNull(); + expect((await offerNow()).offer).toBeNull(); expect(JSON.stringify(team)).not.toContain(KEY); }); @@ -239,7 +247,7 @@ describe("genclass command: POST /api/cli/suggest (src/suggest.ts header)", () = cached_at: null, // the plan page that starts Pro from the command, and what Pro is upgrade_url: "https://genclass.dev/account/billing?plan=team&from=cli", - upgrade_plan: { name: "Pro", price: "$15 / project / month", trial_days: 3 }, + upgrade_plan: { name: "Pro", price: "$15 / developer / month", trial_days: 3 }, remaining_today: 3, limited: false, }); @@ -391,7 +399,7 @@ describe("genclass command: POST /api/cli/suggest (src/suggest.ts header)", () = designs_locked: 2, cached_at: null, upgrade_url: "https://genclass.dev/account/billing?plan=team&from=cli", - upgrade_plan: { name: "Pro", price: "$15 / project / month", trial_days: 3 }, + upgrade_plan: { name: "Pro", price: "$15 / developer / month", trial_days: 3 }, remaining_today: 2, limited: false, }); @@ -535,7 +543,7 @@ describe("genclass command: GET /api/cli/app (src/cli.ts)", () => { expect(j.recent).toEqual([]); expect(j.typeSeries.every((b: any) => Object.keys(b.counts).join() === "problems")).toBe(true); expect(j.typeSeries.reduce((n: number, b: any) => n + b.counts.problems, 0)).toBe(1); - expect(j.locked.problems).toEqual({ plan: "Pro", upgrade_url: `https://genclass.dev/account/billing?project=${app.id}`, trial_days: 3, price: "$15 / project / month" }); + expect(j.locked.problems).toEqual({ plan: "Pro", upgrade_url: `https://genclass.dev/account/billing?project=${app.id}`, trial_days: 3, price: "$15 / developer / month" }); expect(JSON.stringify(j)).not.toContain("/checkout"); // nothing a page typed, and no session of the account expect(JSON.stringify(j)).not.toContain("SECRET PAGE TEXT"); diff --git a/telemetry-worker/test/locked.test.ts b/telemetry-worker/test/locked.test.ts index a8c24f84..89bb2803 100644 --- a/telemetry-worker/test/locked.test.ts +++ b/telemetry-worker/test/locked.test.ts @@ -108,6 +108,9 @@ describe("a locked private link: sign in, add the app to the account, see the pl expect(mine).toContain('See plans'); // the app's own page: the key and the Connect panel, the plans, no stats const id = String(sql.rows("SELECT id FROM projects WHERE token = ?", anon.token)[0]!.id); + // with a plan on sale the app's page opens its plan page first, until a plan is chosen there (plans.json version 5) + expect((await call(env, "GET", `/account/apps/${id}`, { cookie })).headers.get("location")).toBe(`/account/billing?project=${id}`); + sql.db.prepare("UPDATE projects SET plan_chosen = ? WHERE id = ?").run(now().toISOString(), id); const app = await text(call(env, "GET", `/account/apps/${id}`, { cookie })); expect(app).toContain('

    This app\'s plan has no dashboard.

    '); expect(app).toContain(''); diff --git a/telemetry-worker/test/pages.test.ts b/telemetry-worker/test/pages.test.ts index 84ad23c5..9cd9406c 100644 --- a/telemetry-worker/test/pages.test.ts +++ b/telemetry-worker/test/pages.test.ts @@ -655,7 +655,7 @@ describe("dashboard page: interaction analytics (docs/runtime/ANALYTICS.md secti const { doc, text, hidden, close } = await render(st); expect(hidden("prolock")).toBe(false); expect(text("#prolock b")).toBe("Free shows the totals"); - expect(text("#prolockmsg")).toBe("Every problem, the pages it happens on, what GenClass did about it and a fix prompt for your coding agent come with Pro. 3 days free, then $15 / project / month. Cancel any time."); + expect(text("#prolockmsg")).toBe("Every problem, the pages it happens on, what GenClass did about it and a fix prompt for your coding agent come with Pro. 3 days free, then $15 / developer / month. Cancel any time."); expect(doc.getElementById("prolocka")!.getAttribute("href")).toBe("https://genclass.dev/account/billing?project=p1"); expect(text("#prolocka")).toBe("Start your 3-day free trial"); // the lists give way to the banner, so an empty list never reads as "no problems" diff --git a/telemetry-worker/test/plans.test.ts b/telemetry-worker/test/plans.test.ts index 8bd898dc..56191cbf 100644 --- a/telemetry-worker/test/plans.test.ts +++ b/telemetry-worker/test/plans.test.ts @@ -4,7 +4,7 @@ import PLANS_JSON from "../../billing/plans.json"; import SEATS_JSON from "./fixtures/plans-v3-seats.json"; import { resetTokenCache } from "../src/dashboard.js"; import { resetOwnerCache } from "../src/paid.js"; -import { accessOf, byAgreement, cleanSeats, firstPaidWith, firstPlanWith, FREE, GRANT_PLAN, hasAsk, hasProblems, ownedPrice, PAID_PLANS, PLANS, planByLookupKey, planForPrice, priceText, problemsLock, RETIRED_PLAN_IDS, seatsOf, viewRanges, type Plan } from "../src/plans.js"; +import { accessOf, accountProjectsAccess, byAgreement, cleanSeats, firstPaidWith, firstPlanWith, FREE, GRANT_PLAN, hasAsk, hasProblems, ownedPrice, PAID_PLANS, PLANS, planByLookupKey, planForPrice, priceText, problemsLock, RETIRED_PLAN_IDS, seatsOf, viewRanges, type Plan } from "../src/plans.js"; import { isGenClassSubscription, resetStripeCache, retiredSubscription, subscriptionState } from "../src/stripe.js"; import { call, @@ -25,11 +25,11 @@ import { WEBHOOK_SECRET, } from "./account-helpers.js"; -// The real plans (billing/plans.json, version 4, 2026-11-12), with no fixture: Free, Pro (id team; named Team until -// 2026-11-12) and Enterprise. Free finds and fixes the problems and shows their totals (3 apps, 7 days shown, one -// suggestion); Pro is sold on Stripe Checkout per project (US$15 a project a month, 3 days free once per account) and -// adds the problem lists, genclass ask, every suggestion, visitor analytics, forwarding, export, 90 days and the whole -// team (test/project-plans.test.ts); Enterprise is agreed by email and given by GenClass's own grant, with the +// The real plans (billing/plans.json, version 5, 2026-11-13), with no fixture: Free, Pro (id team; named Team until +// 2026-11-12) and Enterprise. Free finds and fixes the problems and shows their totals (one app, 7 days shown, one +// suggestion); Pro is sold on Stripe Checkout for the account, per developer (US$15 a developer a month, up to 3 +// apps, 3 days free once per account), and adds the problem lists, genclass ask, every suggestion, visitor analytics, forwarding, export, 90 days +// and the whole team (test/project-plans.test.ts); Enterprise is agreed by email and given by GenClass's own grant, with the // classifier a person trains. The old trial (/account/trial, 2026-10) stays gone. Selling one paid plan that is not per seat (Individual, until 2026-10-22) is tested on // test/fixtures/plans-individual.json (one-paid tests); the machinery that two paid plans or a locked dashboard reach // on test/fixtures/plans-two-paid.json (billing, locked, dashboard, auth, export and forward tests). @@ -48,13 +48,13 @@ const text = async (r: Promise | Response) => (await r).text(); const visible = (html: string) => html.replace(/||/gi, ""); describe("billing/plans.json: the one source of plans", () => { - it("has Free, Pro (id team) and Enterprise with the owner's prices, limits and entitlements (version 4, 2026-11-12: per project)", () => { + it("has Free, Pro (id team) and Enterprise with the owner's prices, limits and entitlements (version 5, 2026-11-13: per developer, up to 3 apps)", () => { expect(PLANS.map((p) => p.id)).toEqual(["free", "team", "enterprise"]); // the paid plan keeps its id (Stripe's lookup key, the rows in D1) and is called Pro since 2026-11-12 expect(PLANS.map((p) => p.name)).toEqual(["Free", "Pro", "Enterprise"]); const [free, team, enterprise] = PLANS; expect(free!.price_usd_cents).toEqual({ monthly: 0, yearly: null }); - // US$15 a project a month (the owner, 2026-11-12; a developer a month before) + // US$15 a developer a month, for up to 3 apps (the owner, 2026-11-13; a project a month the day before) expect(team!.price_usd_cents).toEqual({ monthly: 1500, yearly: null }); expect(enterprise!.price_usd_cents).toEqual({ monthly: null, yearly: null }); expect(PLANS.map((p) => p.stripe_lookup_keys)).toEqual([ @@ -62,17 +62,24 @@ describe("billing/plans.json: the one source of plans", () => { { monthly: "genclass_team_monthly", yearly: null }, { monthly: null, yearly: null }, ]); - // Free: finds and fixes the problems and shows their totals, for one person; the problem lists and genclass ask - // come with Pro (the owner's "limit free fully", 2026-11-12), and one suggestion is shown - expect(free!.limits).toEqual({ apps: 3, history_days: 7, seats: 1, live_fixes_per_month: 100 }); + // Free: finds and fixes the problems and shows their totals, for one person and one app (version 5); the problem + // lists and genclass ask come with Pro (the owner's "limit free fully", 2026-11-12), and one suggestion is shown + expect(free!.limits).toEqual({ apps: 1, history_days: 7, seats: 1, live_fixes_per_month: 100 }); expect(free!.entitlements).toEqual({ dashboard: true, forwarding: false, export: false, analytics: false, custom_model: false, github_private: false, problems: false, ask: false }); expect(free!.suggestions).toEqual({ per_day: 3, shown: 1 }); - expect(free!.summary).toBe("The totals, one suggestion, up to 3 projects."); - expect(free!.features.map((x) => x.text)).toEqual(expect.arrayContaining(["Dashboard totals, last 7 days", "Suggestions: your top one"])); - // Team: sold per project, everything a team uses but the classifier a person trains - expect(PLANS.map((p) => p.per_seat)).toEqual([false, false, false]); - expect(PLANS.map((p) => p.per_project === true)).toEqual([false, true, false]); - expect(team!.limits).toEqual({ apps: null, history_days: 90, seats: null, live_fixes_per_month: null }); + expect(free!.summary).toMatch(/^One app\b/); + // the owner's words for Free's list (2026-11-13): one app, a limited dashboard, the open-source basics, first + expect(free!.features.slice(0, 2).map((x) => x.text)).toEqual(["One app", "Dashboard totals, last 7 days"]); + expect(free!.features.map((x) => x.text)).toEqual(expect.arrayContaining(["Open-source runtime, every mode", "On-device model", "Coding-agent loop on your machine (@genclass/mcp)", "Self-host the collector"])); + // Pro: sold for the account per developer (per_seat), not per project any more, for up to 3 apps (limits.apps); + // everything a team uses but the classifier a person trains. More apps are Enterprise's + expect(PLANS.map((p) => p.per_seat)).toEqual([false, true, false]); + expect(PLANS.map((p) => p.per_project === true)).toEqual([false, false, false]); + expect(PLANS.map((p) => p.apps_per_unit)).toEqual([undefined, undefined, undefined]); + expect(team!.summary).toBe("Everything for up to 3 apps, per developer."); + expect(team!.features.map((x) => x.text)).toEqual(expect.arrayContaining(["Up to 3 apps", "The genclass command in your terminal", "Your team on it, $15 a developer"])); + expect(enterprise!.features.map((x) => x.text)).toContain("More than 3 apps"); + expect(team!.limits).toEqual({ apps: 3, history_days: 90, seats: null, live_fixes_per_month: null }); expect(team!.entitlements).toEqual({ dashboard: true, forwarding: true, export: true, analytics: true, custom_model: false, github_private: true, problems: true, ask: true }); expect(team!.suggestions).toEqual({ per_day: 30, shown: null }); expect(team!.features[0]).toEqual({ text: "Everything in Free", available: true }); @@ -99,7 +106,7 @@ describe("billing/plans.json: the one source of plans", () => { expect(GRANT_PLAN.id).toBe("enterprise"); expect(FREE.id).toBe("free"); expect(priceText(free!)).toBe("$0"); - expect(priceText(team!)).toBe("$15 / project / month"); + expect(priceText(team!)).toBe("$15 / developer / month"); expect(priceText(enterprise!)).toBe("By agreement"); expect(planByLookupKey("genclass_team_monthly")?.id).toBe("team"); // Individual (until 2026-10-22) and the plans before it name no plan any more @@ -107,7 +114,7 @@ describe("billing/plans.json: the one source of plans", () => { expect(viewRanges(free!)).toEqual(["24h", "7d"]); expect(viewRanges(team!)).toEqual(["24h", "7d", "30d", "90d"]); expect(viewRanges(enterprise!)).toEqual(["24h", "7d", "30d", "90d", "1y"]); - expect((PLANS_JSON as any).version).toBe(4); + expect((PLANS_JSON as any).version).toBe(5); expect((PLANS_JSON as any).footer).toBe("Questions about Pro, Enterprise, your own classifier, self-hosting or invoices? Email mehar@genclass.dev."); expect((PLANS_JSON as any).grant_plan).toBe("enterprise"); expect(PLANS_JSON).not.toHaveProperty("trial_days"); @@ -127,7 +134,7 @@ describe("billing/plans.json: the one source of plans", () => { // the plan an upgrade names: the first one on sale with it expect(firstPaidWith("problems")?.id).toBe("team"); expect(firstPaidWith("ask")?.id).toBe("team"); - expect(problemsLock("https://genclass.dev/account/billing?project=p1")).toEqual({ plan: "Pro", upgrade_url: "https://genclass.dev/account/billing?project=p1", trial_days: 3, price: "$15 / project / month" }); + expect(problemsLock("https://genclass.dev/account/billing?project=p1")).toEqual({ plan: "Pro", upgrade_url: "https://genclass.dev/account/billing?project=p1", trial_days: 3, price: "$15 / developer / month" }); // the per-seat plans of version 3 (test/fixtures/plans-v3-seats.json) say nothing of them: every plan gives them const v3 = (SEATS_JSON as any).plans as Plan[]; expect(v3.every((p) => !("problems" in p.entitlements) && !("ask" in p.entitlements))).toBe(true); @@ -152,17 +159,17 @@ describe("billing/plans.json: the one source of plans", () => { expect(accessOf(row({ plan: "individual", comp_until: later }), "live", at)).toMatchObject({ source: "comp", plan: { id: "enterprise" } }); expect(accessOf(row({ plan: "pro" }), "live", at)).toEqual(none); expect(accessOf(row({ plan: "custom", stripe_mode: "live" }), "live", at)).toEqual(none); - // Team, paid (per project since version 4): no seat count, everyone on the team is in - expect(accessOf(row({ plan: "team", stripe_mode: "live", seats: 4 }), "live", at)).toMatchObject({ source: "paid", plan: { id: "team" }, seats: null }); - expect(accessOf(row({ plan: "team", stripe_mode: "live" }), "live", at)).toMatchObject({ source: "paid", plan: { id: "team" }, seats: null }); + // Pro, paid (per developer since version 5): the seats paid for, at least 1 + expect(accessOf(row({ plan: "team", stripe_mode: "live", seats: 4 }), "live", at)).toMatchObject({ source: "paid", plan: { id: "team" }, seats: 4 }); + expect(accessOf(row({ plan: "team", stripe_mode: "live" }), "live", at)).toMatchObject({ source: "paid", plan: { id: "team" }, seats: 1 }); // bought in test mode: counts only under a test key expect(accessOf(row({ plan: "team", stripe_mode: "test", seats: 4 }), "live", at)).toEqual(none); - expect(accessOf(row({ plan: "team", stripe_mode: "test", seats: 4 }), "test", at)).toMatchObject({ source: "paid", seats: null }); + expect(accessOf(row({ plan: "team", stripe_mode: "test", seats: 4 }), "test", at)).toMatchObject({ source: "paid", seats: 4 }); // a grant names its plan (comp_plan); unknown, empty or free: plans.json grant_plan. A grant has no seat count expect(accessOf(row({ comp_until: later, comp_plan: "team" }), "live", at)).toMatchObject({ source: "comp", plan: { id: "team" }, seats: null }); for (const comp_plan of [null, "", "free", "pro"]) expect(accessOf(row({ comp_until: later, comp_plan }), "live", at), String(comp_plan)).toMatchObject({ source: "comp", plan: { id: "enterprise" } }); // both: the plan listed later applies (a Team grant does not lower a paid Team's seats; an Enterprise grant wins) - expect(accessOf(row({ plan: "team", stripe_mode: "live", seats: 2, comp_until: later, comp_plan: "team" }), "live", at)).toMatchObject({ source: "paid", seats: null }); + expect(accessOf(row({ plan: "team", stripe_mode: "live", seats: 2, comp_until: later, comp_plan: "team" }), "live", at)).toMatchObject({ source: "paid", seats: 2 }); expect(accessOf(row({ plan: "team", stripe_mode: "live", seats: 2, comp_until: later }), "live", at)).toMatchObject({ source: "comp", plan: { id: "enterprise" }, seats: null }); // PLAN_GATES open (a self-hosted collector): everyone, an app without an account too, has the grant plan expect(accessOf(null, null, at, true)).toMatchObject({ source: "open", plan: { id: "enterprise" }, seats: null }); @@ -174,12 +181,12 @@ describe("billing/plans.json: the one source of plans", () => { for (const bad of [0, -1, 201, 1.5, "3", null, undefined, NaN, Infinity]) expect(cleanSeats(bad), String(bad)).toBeNull(); const [free, team, enterprise] = PLANS; expect(seatsOf(free!, null, null)).toBe(1); - // Team is per project since version 4: no seat count, paid or granted - expect(seatsOf(team!, "paid", 5)).toBeNull(); + // Pro is per developer since version 5: paid, what was paid for (at least 1); granted, no seat count + expect(seatsOf(team!, "paid", 5)).toBe(5); + expect(seatsOf(team!, "paid", 0)).toBe(1); expect(seatsOf(team!, "comp", 5)).toBeNull(); - // a plan sold per seat (none today) holds what was paid for - expect(seatsOf({ ...team!, per_seat: true }, "paid", 5)).toBe(5); - expect(seatsOf({ ...team!, per_seat: true }, "paid", 0)).toBe(1); + // a plan not sold per seat holds its own limit + expect(seatsOf({ ...team!, per_seat: false }, "paid", 5)).toBeNull(); expect(seatsOf(enterprise!, "comp", null)).toBeNull(); }); }); @@ -226,7 +233,7 @@ describe("the dashboard is free: every app opens it, with an account or without" // Free sees the totals: the problem lists, pages and latest events come with Pro, from the app's plan page expect(stats.locked).toEqual({ analytics: { plan: "Pro", upgrade_url: "https://genclass.dev/account/billing" }, - problems: { plan: "Pro", upgrade_url: `https://genclass.dev/account/billing?project=${apps[0]!.id}`, trial_days: 3, price: "$15 / project / month" }, + problems: { plan: "Pro", upgrade_url: `https://genclass.dev/account/billing?project=${apps[0]!.id}`, trial_days: 3, price: "$15 / developer / month" }, }); expect([stats.breakdowns, stats.recent]).toEqual([{}, []]); const denied = async (r: Promise, what: string) => { @@ -285,7 +292,7 @@ describe("the dashboard is free: every app opens it, with an account or without" expect(st.totals.detections, range).toBe(1); if (locked) { // the app has no account: the way to Pro is the plans page - expect(st.locked.problems).toEqual({ plan: "Pro", upgrade_url: "https://genclass.dev/account/billing", trial_days: 3, price: "$15 / project / month" }); + expect(st.locked.problems).toEqual({ plan: "Pro", upgrade_url: "https://genclass.dev/account/billing", trial_days: 3, price: "$15 / developer / month" }); expect([st.breakdowns, st.recent]).toEqual([{}, []]); } else { expect(st.locked).toBeUndefined(); @@ -343,29 +350,31 @@ describe("the old trial stays gone; Pro's 3 days free are offered on the plan pa // the app's plan page, for an account that has never had a subscription: the trial, and what follows it const eligible = pages.find(([p]) => p.startsWith("/account/billing?project="))![1]; expect(eligible).toContain('data-plan="team" data-pick>Start your 3-day free trial'); - expect(eligible).toContain('

    3 days free, then $15 / project / month for a. Cancel before the trial ends and you pay nothing.

    '); + expect(eligible).toContain('

    3 days free, then $15 / developer / month. Cancel before the trial ends and you pay nothing.

    '); expect(visible(eligible)).not.toMatch(/—|!/); const planPage = () => text(call(env, "GET", `/account/billing?project=${app.id}`, { cookie })); const noTrial = (html: string, why: string) => { - expect(html, why).toContain('data-plan="team" data-pick>Start Pro for a'); + expect(html, why).toContain('data-plan="team" data-pick>Start Pro'); expect(html, why).not.toContain("free trial"); expect(html, why).not.toContain('class="trialnote"'); }; - // an account that had a subscription of its own (before plans were per project), even one cancelled long ago + // an account that had a subscription of its own, even one cancelled long ago setPlan(sql, account.id, "free", { subscription_id: "sub_old0000001", subscription_status: "canceled" }); noTrial(await planPage(), "an account subscription"); setPlan(sql, account.id, "free", { subscription_id: null, subscription_status: null }); expect(await planPage()).toContain("Start your 3-day free trial"); - // one of its projects had a subscription + // one of its projects had a subscription (version 4) const other = await createApp(env, cookie, "b"); sql.db.prepare("UPDATE projects SET subscription_id = 'sub_b000000001', subscription_status = 'canceled' WHERE id = ?").run(other.id); noTrial(await planPage(), "a project's subscription"); - // a project on GenClass's grant can start paying any time, with no trial + // an app on GenClass's grant (which covers every app of the account since version 5) goes on with it: no trial sql.db.prepare("UPDATE projects SET subscription_id = NULL, subscription_status = NULL WHERE id = ?").run(other.id); sql.db.prepare("UPDATE projects SET comp_until = '9999-12-31T00:00:00.000Z', comp_plan = 'team' WHERE id = ?").run(app.id); const granted = await planPage(); - expect(granted).toContain("a has Pro, set up by GenClass."); - noTrial(granted, "a grant"); + expect(granted).toContain("Your Pro plan, set up by GenClass, covers a."); + expect(granted).toContain(''); + expect(granted).not.toContain("free trial"); + expect(granted).not.toContain("data-pick"); }); it("nothing in src/ asks Stripe for a trial but Checkout's plan trial_days, or writes the old trial's columns", () => { @@ -396,8 +405,8 @@ describe("the old trial stays gone; Pro's 3 days free are offered on the plan pa }); }); -describe("Pro (id team) is the one plan sold on the site, per project; Enterprise by email", () => { - it("checkout sells Pro and nothing else, for one project, and never calls Stripe for another plan", async () => { +describe("Pro (id team) is the one plan sold on the site, for the account; Enterprise by email", () => { + it("checkout sells Pro and nothing else, for the account, and never calls Stripe for another plan", async () => { const { env } = setup(LIVE); const { cookie } = await signedIn(env); const s = fakeStripe(); @@ -406,13 +415,24 @@ describe("Pro (id team) is the one plan sold on the site, per project; Enterpris expect(r.status, plan).toBe(400); expect(((await r.json()) as any).error).toBe("choose a plan: team"); } - const r = await call(env, "POST", "/api/billing/checkout", { body: { plan: "team", seats: 3 }, cookie, fetch: s.fetch }); - expect(r.status).toBe(400); - expect(((await r.json()) as any).error).toBe("the Pro plan is bought for one project: send project_id"); expect(s.calls).toEqual([]); + // seats that are not 1 to 200 whole developers are refused before Stripe is asked + for (const seats of [0, 201, 1.5, "2"]) { + const bad = await call(env, "POST", "/api/billing/checkout", { body: { plan: "team", seats }, cookie, fetch: s.fetch }); + expect(bad.status, String(seats)).toBe(400); + expect(((await bad.json()) as any).error).toBe("seats is a whole number from 1 to 200; for a larger team email mehar@genclass.dev"); + } + expect(s.calls).toEqual([]); + // Pro is the account's, per developer (version 5): no project needed, the quantity is the seats asked for + const r = await call(env, "POST", "/api/billing/checkout", { body: { plan: "team", seats: 3 }, cookie, fetch: s.fetch }); + expect(r.status).toBe(200); + const session = s.calls.find((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions")!; + expect(session.form!.get("line_items[0][quantity]")).toBe("3"); + expect(session.form!.get("metadata[genclass_seats]")).toBe("3"); + expect(session.form!.has("metadata[genclass_project]")).toBe(false); }); - it("an account's own subscription from before plans were per project: the webhook still writes the account, and it covers every app", async () => { + it("the account's subscription: the webhook writes the account and its seats; it covers the account's first 3 apps, whatever the seats", async () => { const t = setup(LIVE); const a = await signedIn(t.env); const s = fakeStripe(); @@ -420,22 +440,29 @@ describe("Pro (id team) is the one plan sold on the site, per project; Enterpris s.genclassCustomer("cus_ada0000001", a.account.id); const app = await createApp(t.env, a.cookie, "Shop"); const sub = (status = "active") => subscription("sub_ada0000001", a.account.id, "cus_ada0000001", "genclass_team_monthly", status, { items: { data: [{ id: "si_ada0000001", current_period_end: 1_792_000_000, quantity: 4, price: price("genclass_team_monthly") }] } }); - // Team is not sold per seat any more: a quantity is not a seat count - expect(subscriptionState(sub())).toMatchObject({ plan: "team", priced: "team", seats: null }); + // Pro is sold per developer (version 5): the quantity is the seats + expect(subscriptionState(sub())).toMatchObject({ plan: "team", priced: "team", seats: 4 }); const log = vi.spyOn(console, "log").mockImplementation(() => {}); const apply = async (o: Record) => { s.subs.set("sub_ada0000001", o); expect(await (await deliver(t.env, stripeEvent("customer.subscription.updated", o), s.fetch)).json()).toEqual({ received: true, result: "applied" }); return t.sql.rows("SELECT plan, seats FROM accounts")[0]; }; - expect(await apply(sub())).toEqual({ plan: "team", seats: null }); - expect(await (await call(t.env, "GET", "/api/billing", { cookie: a.cookie })).json()).toMatchObject({ plan: "team", access: "paid", seats: null }); + expect(await apply(sub())).toEqual({ plan: "team", seats: 4 }); + // the account holds the 4 people paid for + expect(await (await call(t.env, "GET", "/api/billing", { cookie: a.cookie })).json()).toMatchObject({ plan: "team", access: "paid", seats: 4 }); expect(((await (await call(t.env, "GET", `/api/account/projects/${app.id}/stats?range=90d`, { cookie: a.cookie })).json()) as any).range).toBe("90d"); - expect(await apply(sub("canceled"))).toEqual({ plan: "free", seats: null }); + // up to 3 apps, oldest first, however many seats; a 4th is past Pro's (more apps are Enterprise's) + for (const n of ["Blog", "Docs", "Extra"]) await createApp(t.env, a.cookie, n); + const plans = await accountProjectsAccess(t.sql.api, a.account.id, t.env, new Date()); + expect(plans.map((x) => x.access.locked ?? x.access.plan.id)).toEqual(["team", "team", "team", "pro_apps"]); + expect(plans.map((x) => x.access.apps)).toEqual([3, 3, 3, 3]); + const after = await apply(sub("canceled")); + expect(after!.plan).toBe("free"); log.mockRestore(); }); - it("the billing page: three plans; Team per project with Choose a project; Talk to us for Enterprise", async () => { + it("the billing page: three plans; Pro for the account, per developer, with its seats and button; Talk to us for Enterprise", async () => { for (const keys of [{}, LIVE]) { const t = setup(keys); const a = await signedIn(t.env); @@ -444,12 +471,19 @@ describe("Pro (id team) is the one plan sold on the site, per project; Enterpris const live = "STRIPE_SECRET_KEY" in keys; expect(page).toContain(live ? "You are on the Free plan.

    " : "You are on the Free plan. Paid plans are not on sale yet.

    "); expect(page).toContain("grid-template-columns:repeat(3,minmax(0,1fr))"); - expect(page).toContain('

    $15 / project / month

    '); - expect(page).toContain("
  • Your whole team on the project
  • "); + expect(page).toContain('

    $15 / developer / month

    '); + expect(page).toContain("
  • Your team on it, $15 a developer
  • "); + expect(page).toContain("
  • More than 3 apps
  • "); expect(page.match(/
  • Your own classifier/g)).toHaveLength(1); - if (live) expect(page).toContain('Choose a project'); - else expect(page).not.toContain("Choose a project"); - expect(page).not.toContain('id="seats-team"'); + // bought for the account here, with how many developers (no project to choose first) + if (live) { + expect(page).toContain('data-plan="team">Upgrade to Pro'); + expect(page).toContain('id="seats-team"'); + } else { + expect(page).not.toContain("data-plan="); + expect(page).not.toContain('id="seats-team"'); + } + expect(page).not.toContain("Choose a project"); expect(page).toContain("ShopFree"); expect(page).toContain('Talk to us'); expect(page).toContain('

    By agreement

    '); @@ -457,12 +491,12 @@ describe("Pro (id team) is the one plan sold on the site, per project; Enterpris } }); - it("a paid Pro account (an old account subscription): the status line says the price; a server with PLAN_GATES open says it sells nothing", async () => { + it("a paid Pro account: the status line says its seats and the price; a server with PLAN_GATES open says it sells nothing", async () => { const t = setup(LIVE); const a = await signedIn(t.env); setPlan(t.sql, a.account.id, "team", { stripe_customer_id: "cus_b000000001", stripe_mode: "live", seats: 3, subscription_id: "sub_b000000001", subscription_status: "active", current_period_end: "2026-11-13T12:00:00.000Z" }); const page = await text(call(t.env, "GET", "/account/billing", { cookie: a.cookie })); - expect(page).toContain('You are on the Pro plan, $15 / project / month. It renews on .'); + expect(page).toContain('You are on the Pro plan with 3 seats, $15 / developer / month ($45 a month). It renews on . Add or remove seats in Manage billing.'); expect(page).toContain('Your plan'); const open = setup({ PLAN_GATES: "open" }); const b = await signedIn(open.env); diff --git a/telemetry-worker/test/project-plans.test.ts b/telemetry-worker/test/project-plans.test.ts index 24a421b6..5a709dc2 100644 --- a/telemetry-worker/test/project-plans.test.ts +++ b/telemetry-worker/test/project-plans.test.ts @@ -1,20 +1,24 @@ -// Plans per project (billing/plans.json version 4, the owner's decision of 2026-11-12): Team is $15 a month for one -// project, everyone on the account's team included; Free covers an account's first 3 projects and 100 problems fixed -// live a month per project, after which the runtime is told to observe only. Checkout, the webhook, the pages, the -// lock on a fourth free project, the live-fix limit at ingest, and what a project that is paid for cannot lose. +// Pro for the account again (billing/plans.json version 5, the owner's decision of 2026-11-13): Pro is US$15 a +// developer a month for up to 3 of the account's apps (oldest first), everyone on its team included; more apps are +// Enterprise's, by email. Free covers one app and 100 problems fixed live a month, after which the runtime is told to +// observe only. A subscription bought per project under version 4 still gives the account Pro (and its 3 apps), and +// GenClass's grants cover every app. Coverage, Checkout and its trial, the webhook, every app's plan page (Continue +// with Pro, the trial, Email us about Enterprise), the website's redirect to it, the live-fix limit at ingest, and +// what a project that is paid for cannot lose. import { beforeEach, describe, expect, it, vi } from "vitest"; import { resetTokenCache } from "../src/dashboard.js"; -import { resetOwnerCache } from "../src/paid.js"; -import { accountWide, FREE, priceText, PLANS } from "../src/plans.js"; +import { ownerOf, resetOwnerCache } from "../src/paid.js"; +import { accessIn, accountProjectsAccess, byQuantity, coverage, FREE, priceText, PLANS } from "../src/plans.js"; import { FREE_REASONS, trialFor } from "../src/billing.js"; import { FREE_REASONS_SHOWN } from "../src/billing-pages.js"; import { resetStripeCache } from "../src/stripe.js"; import { monthOf, OBSERVE_HEADER, resetUsageCache } from "../src/usage.js"; -import { call, createApp, deliver, fakeStripe, GENCLASS_PORTAL, price, resetClock, sendBatch, setPlan, setup, signedIn, stripeEvent, subscription, WEBHOOK_SECRET } from "./account-helpers.js"; +import { call, createApp, deliver, fakeStripe, GENCLASS_PORTAL, now, price, resetClock, sendBatch, setPlan, setup, signedIn, stripeEvent, subscription, WEBHOOK_SECRET } from "./account-helpers.js"; const LIVE = { STRIPE_SECRET_KEY: "sk_live_fake", STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET, STRIPE_PORTAL_CONFIGURATION: GENCLASS_PORTAL }; const text = async (r: Promise | Response) => (await r).text(); const json = async (r: Promise | Response) => (await (await r).json()) as any; +type Sql = ReturnType["sql"]; beforeEach(() => { resetClock(); @@ -24,9 +28,20 @@ beforeEach(() => { resetUsageCache(); }); -/** A project's subscription as Stripe returns it: GenClass's Team price, its metadata naming the project. */ +/** A version-4 subscription of one project as Stripe returns it: GenClass's Pro price, its metadata naming the project. */ const projectSub = (id: string, accountId: string, customer: string, projectId: string, status = "active") => subscription(id, accountId, customer, "genclass_team_monthly", status, { metadata: { genclass: "1", genclass_account: accountId, genclass_project: projectId } }); +/** The account's own Pro subscription (version 5) with `seats` developers, as Stripe returns it. */ +const accountSub = (id: string, accountId: string, customer: string, status = "active", seats = 1) => + subscription(id, accountId, customer, "genclass_team_monthly", status, { items: { data: [{ id: `si_${id.slice(4)}`, current_period_end: 1_792_000_000, quantity: seats, price: price("genclass_team_monthly") }] } }); +/** The account on Pro, as the webhook leaves it. */ +const paidPro = (sql: Sql, accountId: string, seats = 1) => + setPlan(sql, accountId, "team", { stripe_mode: "live", seats, subscription_id: "sub_acct000001", subscription_status: "active", current_period_end: "2026-11-13T12:00:00.000Z" }); +/** One app paid for on its own, as the version-4 webhook left it. */ +const projectPaid = (sql: Sql, projectId: string) => + sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_id = ?, subscription_status = 'active' WHERE id = ?").run(`sub_${projectId.slice(0, 10)}`, projectId); +const chosenAt = (sql: Sql, id: string) => (sql.db.prepare("SELECT plan_chosen FROM projects WHERE id = ?").get(id) as { plan_chosen: string | null }).plan_chosen; +const sessionsOf = (s: ReturnType) => s.calls.filter((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions").map((c) => c.form!); /** An account with a Stripe customer GenClass made, and two apps. */ async function buyer() { @@ -40,107 +55,273 @@ async function buyer() { return { ...t, ...a, s, shop, blog }; } -describe("plans.json version 4: Team per project", () => { - it("Team is $15 a project a month, not per seat; Free covers 3 projects and 100 live fixes a month each", () => { +/** An account on `LIVE` with apps of these names, oldest first. */ +async function withApps(names: string[], over: Record = {}) { + const t = setup({ ...LIVE, ...over }); + const a = await signedIn(t.env); + const apps = []; + for (const n of names) apps.push(await createApp(t.env, a.cookie, n)); + return { ...t, ...a, apps }; +} + +describe("plans.json version 5: Pro for the account, per developer, up to 3 apps", () => { + it("Pro is $15 a developer a month, sold by seats, for up to 3 apps; Free covers one app and 100 live fixes a month", () => { const [free, team, enterprise] = PLANS; - expect(priceText(team!)).toBe("$15 / project / month"); - expect(team!.per_project).toBe(true); - expect(team!.per_seat).toBe(false); - expect(free!.limits).toEqual({ apps: 3, history_days: 7, seats: 1, live_fixes_per_month: 100 }); + expect(priceText(team!)).toBe("$15 / developer / month"); + expect(team!.per_project).toBeUndefined(); + expect(team!.per_seat).toBe(true); + expect(team!.apps_per_unit).toBeUndefined(); + expect(byQuantity(team!)).toBe(true); + expect(team!.limits.apps).toBe(3); + expect(free!.limits).toEqual({ apps: 1, history_days: 7, seats: 1, live_fixes_per_month: 100 }); expect(team!.limits.live_fixes_per_month).toBeNull(); - expect(enterprise!.limits.live_fixes_per_month).toBeNull(); - expect(free!.features.map((f) => f.text)).toContain("100 live fixes a month per project, then observe only"); - expect(team!.features.map((f) => f.text)).toContain("Unlimited live fixes"); + expect(enterprise!.limits).toMatchObject({ apps: null, live_fixes_per_month: null }); + expect(team!.features.map((f) => f.text)).toEqual(expect.arrayContaining(["Up to 3 apps", "Unlimited live fixes"])); + expect(enterprise!.features.map((f) => f.text)).toContain("More than 3 apps"); }); +}); - it("an account's grant of Team covers only the apps it was copied to; an Enterprise grant and an old account subscription cover them all", () => { - const at = new Date("2026-11-12T12:00:00Z"); - const later = "2027-02-01T00:00:00.000Z"; - expect(accountWide({ plan: "free", stripe_mode: null, comp_until: later, comp_plan: "team" }, "live", at).plan.id).toBe("free"); - expect(accountWide({ plan: "free", stripe_mode: null, comp_until: later, comp_plan: null }, "live", at).plan.id).toBe("enterprise"); - expect(accountWide({ plan: "team", stripe_mode: "live", comp_until: null }, "live", at)).toMatchObject({ source: "paid", plan: { id: "team" } }); +describe("coverage: what the account's plan covers (src/plans.ts coverage, accessIn)", () => { + const plansOf = async (t: { sql: Sql; env: any; account: { id: string } }) => + (await accountProjectsAccess(t.sql.api, t.account.id, t.env, now())).map((x) => x.access.locked ?? `${x.access.plan.id}${x.access.source ? `:${x.access.source}` : ""}`); + + it("Free covers the account's first app; the next waits for Pro", async () => { + const t = await withApps(["Shop", "Blog", "Docs"]); + expect(await plansOf(t)).toEqual(["free", "free_projects", "free_projects"]); + const cov = await coverage(t.sql.api, t.account.id, "live", now()); + expect(cov.cap).toBe(1); + expect(cov.access).toMatchObject({ source: null, apps: 1, plan: { id: "free" } }); + expect(cov.rows.map((r) => r.id)).toEqual(t.apps.map((a) => a.id)); + // a locked app has Free's plan and says how many the plan covers + expect(accessIn(cov, { token: t.apps[1]!.token })).toMatchObject({ plan: { id: "free" }, source: null, locked: "free_projects", apps: 1 }); + // an app not in the list (none of the account's) gets the account's access, never a lock + expect(accessIn(cov, { token: "gc_notthisaccountsapp0000" }).locked).toBeUndefined(); }); -}); -describe("checkout for one project (POST /api/billing/checkout { plan, project_id })", () => { - it("Team is bought for a project of the account, once; nothing goes to Stripe otherwise", async () => { - const b = await buyer(); - const none = await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team" }, cookie: b.cookie, fetch: b.s.fetch }); - expect(none.status).toBe(400); - // the plan id stays team; its name is Pro since 2026-11-12 - expect((await none.json() as any).error).toBe("the Pro plan is bought for one project: send project_id"); - const other = await signedIn(b.env, "bob@example.com"); - const theirs = await createApp(b.env, other.cookie, "Theirs"); - expect((await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team", project_id: theirs.id }, cookie: b.cookie, fetch: b.s.fetch })).status).toBe(404); - expect(b.s.calls.filter((c) => c.url.pathname === "/v1/checkout/sessions" && c.method === "POST")).toEqual([]); + it("Pro covers the first 3 apps, oldest first, however many seats; a 4th is past it", async () => { + const t = await withApps(["Shop", "Blog", "Docs", "Extra", "More"]); + for (const seats of [1, 2, 5]) { + paidPro(t.sql, t.account.id, seats); + expect(await plansOf(t), String(seats)).toEqual(["team:paid", "team:paid", "team:paid", "pro_apps", "pro_apps"]); + const cov = await coverage(t.sql.api, t.account.id, "live", now()); + expect(cov.cap).toBe(3); + expect(cov.access).toMatchObject({ source: "paid", seats, apps: 3 }); + } + // a locked app reads as Free, with the cap and the lock + const cov = await coverage(t.sql.api, t.account.id, "live", now()); + expect(accessIn(cov, { id: t.apps[3]!.id })).toMatchObject({ plan: { id: "free" }, source: null, locked: "pro_apps", apps: 3 }); + // an app removed: the next one takes its place + t.sql.db.prepare("DELETE FROM projects WHERE id = ?").run(t.apps[1]!.id); + expect(await plansOf(t)).toEqual(["team:paid", "team:paid", "team:paid", "pro_apps"]); + // bought in test mode: counts only under a test key + setPlan(t.sql, t.account.id, "team", { stripe_mode: "test" }); + expect(await plansOf(t)).toEqual(["free", "free_projects", "free_projects", "free_projects"]); + }); + + it("a version-4 subscription of one project gives the account Pro and its 3 apps; two of them still cover 3", async () => { + const t = await withApps(["Shop", "Blog", "Docs", "Extra"]); + projectPaid(t.sql, t.apps[1]!.id); + expect(await plansOf(t)).toEqual(["team:paid", "team:paid", "team:paid", "pro_apps"]); + expect((await coverage(t.sql.api, t.account.id, "live", now())).cap).toBe(3); + projectPaid(t.sql, t.apps[2]!.id); + expect((await coverage(t.sql.api, t.account.id, "live", now())).cap).toBe(3); + expect(await plansOf(t)).toEqual(["team:paid", "team:paid", "team:paid", "pro_apps"]); + // an app that still pays for itself is never locked, wherever it is in the list: a 4th with its own subscription + projectPaid(t.sql, t.apps[3]!.id); + expect(await plansOf(t)).toEqual(["team:paid", "team:paid", "team:paid", "team:paid"]); + t.sql.db.prepare("UPDATE projects SET plan = NULL, subscription_id = NULL, subscription_status = NULL WHERE id = ?").run(t.apps[3]!.id); + expect(await plansOf(t)).toEqual(["team:paid", "team:paid", "team:paid", "pro_apps"]); + // with the account's own subscription as well: still 3 + paidPro(t.sql, t.account.id, 2); + expect((await coverage(t.sql.api, t.account.id, "live", now())).cap).toBe(3); + // a cancelled one is gone: the account is on Free again + t.sql.db.prepare("UPDATE projects SET plan = NULL, subscription_status = 'canceled' WHERE account_id = ?").run(t.account.id); + setPlan(t.sql, t.account.id, "free", { subscription_status: "canceled" }); + expect(await plansOf(t)).toEqual(["free", "free_projects", "free_projects", "free_projects"]); + }); + + it("GenClass's grant covers every app: the account's (Enterprise or Pro), or one copied onto an app", async () => { + const t = await withApps(["a", "b", "c", "d", "e"]); + const later = "9999-12-31T00:00:00.000Z"; + setPlan(t.sql, t.account.id, "free", { comp_until: later }); + expect(await plansOf(t)).toEqual(Array(5).fill("enterprise:comp")); + expect((await coverage(t.sql.api, t.account.id, "live", now())).cap).toBeNull(); + setPlan(t.sql, t.account.id, "free", { comp_plan: "team" }); + expect(await plansOf(t)).toEqual(Array(5).fill("team:comp")); + // over a paid Pro too: a grant of Enterprise is listed later, and has no cap + paidPro(t.sql, t.account.id); + setPlan(t.sql, t.account.id, "team", { comp_until: later, comp_plan: null }); + expect(await plansOf(t)).toEqual(Array(5).fill("enterprise:comp")); + // a grant migration 0016 copied onto one app + setPlan(t.sql, t.account.id, "free", { comp_until: null, comp_plan: null, stripe_mode: null, subscription_id: null, subscription_status: null }); + t.sql.db.prepare("UPDATE projects SET comp_until = ?, comp_plan = 'team' WHERE id = ?").run(later, t.apps[4]!.id); + expect(await plansOf(t)).toEqual(Array(5).fill("team:comp")); + // ended: Free again + t.sql.db.prepare("UPDATE projects SET comp_until = '2026-01-01T00:00:00.000Z' WHERE id = ?").run(t.apps[4]!.id); + expect(await plansOf(t)).toEqual(["free", "free_projects", "free_projects", "free_projects", "free_projects"]); }); - it("the session: quantity 1, the project in its metadata and its subscription's, back to the project's page", async () => { + it("PLAN_GATES open: every app has the grant plan, nothing locked", async () => { + const t = await withApps(["a", "b", "c", "d"], { PLAN_GATES: "open" }); + expect(await plansOf(t)).toEqual(Array(4).fill("enterprise:open")); + }); + + it("ingest's owner (src/paid.ts ownerOf) has the account's plan for a covered app and Free's for an app past the cap", async () => { + const t = await withApps(["Shop", "Blog", "Docs", "Extra"]); + const planOf = async (i: number) => { + resetOwnerCache(); + return (await ownerOf(t.sql.api, t.apps[i]!.token, now().getTime(), "live"))!.plan.id; + }; + // Free: the first app's plan is Free, and so is every other's + expect([await planOf(0), await planOf(1)]).toEqual(["free", "free"]); + paidPro(t.sql, t.account.id); + expect([await planOf(0), await planOf(1), await planOf(2), await planOf(3)]).toEqual(["team", "team", "team", "free"]); + const owner = await ownerOf(t.sql.api, t.apps[3]!.token, now().getTime(), "live"); + expect(owner).toMatchObject({ projectId: t.apps[3]!.id, accountId: t.account.id, name: "Extra" }); + // the grant covers it + setPlan(t.sql, t.account.id, "team", { comp_until: "9999-12-31T00:00:00.000Z" }); + expect(await planOf(3)).toBe("enterprise"); + // an app with no account is Free's + const anon = (await (await call(t.env, "POST", "/api/projects", { body: { name: "anon" }, origin: null })).json()) as any; + resetOwnerCache(); + expect((await ownerOf(t.sql.api, anon.token, now().getTime(), "live"))!.plan.id).toBe("free"); + }); + + it("an app past the cap does not forward its batches: only the covered apps' destinations get them", async () => { + const t = await withApps(["Shop", "Blog", "Docs", "Extra"], { INTEGRATIONS_KEY: Buffer.from(new Uint8Array(32).map((_, i) => i * 7 + 1)).toString("base64") }); + paidPro(t.sql, t.account.id); + const slack = "https://hooks.slack.com/services/T000/B000/XXXXXXXXXXXX"; + // a destination set up while the 4th app was covered (on Enterprise's grant), then the grant ended + setPlan(t.sql, t.account.id, "team", { comp_until: "9999-12-31T00:00:00.000Z" }); + for (const i of [0, 3]) expect((await call(t.env, "PUT", `/api/account/projects/${t.apps[i]!.id}/integrations/slack`, { body: { url: slack }, cookie: t.cookie })).status, String(i)).toBe(200); + setPlan(t.sql, t.account.id, "team", { comp_until: null }); + resetOwnerCache(); + const sent: string[] = []; + const f = (async (url: string) => (sent.push(String(url)), new Response("ok"))) as never; + const { detection } = await import("./account-helpers.js"); + await sendBatch(t.env, t.apps[0]!.token, [{ t: "session", host: "shop.example", route: "/" }, ...detection()], { sid: "aaaaaaaaaaaa", fetch: f }); + const covered = sent.length; + expect(covered).toBeGreaterThan(0); + await sendBatch(t.env, t.apps[3]!.token, [{ t: "session", host: "extra.example", route: "/" }, ...detection()], { sid: "bbbbbbbbbbbb", fetch: f }); + expect(sent.length).toBe(covered); + }); +}); + +describe("checkout for the account (POST /api/billing/checkout { plan, seats?, project_id? })", () => { + it("Pro is bought for the account: one developer by default, adjustable on Stripe; the session names no project", async () => { const b = await buyer(); - const r = await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team", project_id: b.shop.id }, cookie: b.cookie, fetch: b.s.fetch }); + const r = await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team" }, cookie: b.cookie, fetch: b.s.fetch }); expect(r.status).toBe(200); - const session = b.s.calls.find((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions")!; - const form = Object.fromEntries(session.form!); - expect(form).toMatchObject({ + const [form] = sessionsOf(b.s); + expect(Object.fromEntries(form!)).toMatchObject({ mode: "subscription", customer: "cus_ada0000001", + client_reference_id: b.account.id, "line_items[0][price]": "price_team000001", "line_items[0][quantity]": "1", - // the "Add promotion code" field + "line_items[0][adjustable_quantity][enabled]": "true", + "line_items[0][adjustable_quantity][minimum]": "1", + "line_items[0][adjustable_quantity][maximum]": "200", allow_promotion_codes: "true", - "metadata[genclass_project]": b.shop.id, "metadata[genclass_plan]": "team", - "subscription_data[metadata][genclass_project]": b.shop.id, + "metadata[genclass_seats]": "1", "subscription_data[metadata][genclass_account]": b.account.id, - success_url: `https://genclass.dev/account/billing?project=${b.shop.id}&done=1`, - cancel_url: `https://genclass.dev/account/billing?project=${b.shop.id}`, + // no app's plan page asked: back to the account's billing page + success_url: "https://genclass.dev/account/billing?done=1", + cancel_url: "https://genclass.dev/account/billing", }); - expect(session.form!.has("line_items[0][adjustable_quantity][enabled]")).toBe(false); - // the other project's checkout is a session of its own - expect((await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team", project_id: b.blog.id }, cookie: b.cookie, fetch: b.s.fetch })).status).toBe(200); - const sessions = b.s.calls.filter((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions").map((c) => c.form!.get("metadata[genclass_project]")); - expect(sessions).toEqual([b.shop.id, b.blog.id]); + // an account subscription: the webhook writes the account, never one app + expect(form!.has("metadata[genclass_project]")).toBe(false); + expect(form!.has("subscription_data[metadata][genclass_project]")).toBe(false); + }); + + it("started from an app's plan page: Stripe returns there; the seats asked for are the quantity; another account's app is no page to return to", async () => { + const b = await buyer(); + const done = () => { + for (const x of b.s.sessions.values()) x.status = "complete"; + }; + const r = await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team", project_id: b.blog.id }, cookie: b.cookie, fetch: b.s.fetch }); + expect(r.status).toBe(200); + expect(Object.fromEntries(sessionsOf(b.s)[0]!)).toMatchObject({ + success_url: `https://genclass.dev/account/billing?project=${b.blog.id}&done=1`, + cancel_url: `https://genclass.dev/account/billing?project=${b.blog.id}`, + "line_items[0][quantity]": "1", + }); + // the page's project is where to come back to, not what is bought + expect(sessionsOf(b.s)[0]!.has("metadata[genclass_project]")).toBe(false); + done(); + expect((await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team", project_id: b.shop.id, seats: 4 }, cookie: b.cookie, fetch: b.s.fetch })).status).toBe(200); + expect(Object.fromEntries(sessionsOf(b.s)[1]!)).toMatchObject({ "line_items[0][quantity]": "4", "metadata[genclass_seats]": "4", success_url: `https://genclass.dev/account/billing?project=${b.shop.id}&done=1` }); + done(); + const other = await signedIn(b.env, "bob@example.com"); + const theirs = await createApp(b.env, other.cookie, "Theirs"); + expect((await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team", project_id: theirs.id }, cookie: b.cookie, fetch: b.s.fetch })).status).toBe(200); + expect(Object.fromEntries(sessionsOf(b.s)[2]!)).toMatchObject({ success_url: "https://genclass.dev/account/billing?done=1", cancel_url: "https://genclass.dev/account/billing" }); + expect(JSON.stringify([...sessionsOf(b.s)[2]!])).not.toContain(theirs.id); + // a browser write only: a terminal and another origin are refused before Stripe is asked + const before = b.s.calls.length; + expect((await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team" }, cookie: b.cookie, origin: "https://evil.example", fetch: b.s.fetch })).status).toBe(403); + expect((await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team" }, fetch: b.s.fetch })).status).toBe(401); + expect(b.s.calls.length).toBe(before); + }); + + it("an account on Pro cannot buy it again; a server that sells nothing sells nothing", async () => { + const b = await buyer(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const sub = accountSub("sub_ada0000001", b.account.id, "cus_ada0000001"); + b.s.subs.set(sub.id, sub); + expect(await json(deliver(b.env, stripeEvent("customer.subscription.created", sub), b.s.fetch))).toEqual({ received: true, result: "applied" }); + expect(b.sql.rows("SELECT plan, seats, subscription_id FROM accounts")[0]).toEqual({ plan: "team", seats: 1, subscription_id: "sub_ada0000001" }); + const again = await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team", project_id: b.shop.id }, cookie: b.cookie, fetch: b.s.fetch }); + expect(again.status).toBe(409); + expect((await again.json() as any).error).toBe("you are already on the Pro plan"); + expect(sessionsOf(b.s)).toEqual([]); + log.mockRestore(); + const none = setup(); + const a = await signedIn(none.env); + expect((await call(none.env, "POST", "/api/billing/checkout", { body: { plan: "team" }, cookie: a.cookie, fetch: b.s.fetch })).status).toBe(503); }); }); describe("Pro's trial: 3 days free at Checkout, once per account (src/billing.ts trialFor, the owner's decision of 2026-11-12)", () => { - const sessionsOf = (s: ReturnType) => s.calls.filter((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions").map((c) => c.form!); - const checkout = (b: Awaited>, projectId: string) => call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team", project_id: projectId }, cookie: b.cookie, fetch: b.s.fetch }); + const checkout = (b: Awaited>, projectId: string, extra: Record = {}) => + call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team", project_id: projectId, ...extra }, cookie: b.cookie, fetch: b.s.fetch }); - it("the first purchase starts with 3 days free; once a project of the account has had a subscription, no purchase does", async () => { + it("the first purchase starts with 3 days free; once the account has had a subscription, no purchase does", async () => { const b = await buyer(); const first = await checkout(b, b.shop.id); expect(first.status).toBe(200); expect(await first.json()).toEqual({ url: expect.stringMatching(/^https:\/\/checkout\.stripe\.com\//), trial_days: 3 }); const [form] = sessionsOf(b.s); expect(form!.get("subscription_data[trial_period_days]")).toBe("3"); + expect(form!.get("metadata[genclass_trial_days]")).toBe("3"); // the card is taken at Checkout, and the "Add promotion code" field is there expect(form!.get("payment_method_collection")).toBe("always"); expect(form!.get("allow_promotion_codes")).toBe("true"); - // the shop's Checkout is completed (Stripe closes the page) and its subscription is written by the webhook: the - // account has had one now + // Checkout is completed (Stripe closes the page) and the account's subscription is written by the webhook, in + // its trial: every app the plan covers goes on with Pro b.s.sessions.get("cs_test_000001")!.status = "complete"; const log = vi.spyOn(console, "log").mockImplementation(() => {}); - const sub = projectSub("sub_shop000001", b.account.id, "cus_ada0000001", b.shop.id); - b.s.subs.set(sub.id, sub); - expect(await json(deliver(b.env, stripeEvent("customer.subscription.created", sub), b.s.fetch))).toEqual({ received: true, result: "applied" }); - const second = await checkout(b, b.blog.id); - expect(second.status).toBe(200); - expect((await second.json() as any).trial_days).toBeNull(); + // the card Checkout took (a trial without one is never written) + const trialing = { ...accountSub("sub_ada0000001", b.account.id, "cus_ada0000001", "trialing"), default_payment_method: "pm_card00000001" }; + b.s.subs.set(trialing.id, trialing); + expect(await json(deliver(b.env, stripeEvent("customer.subscription.created", trialing), b.s.fetch))).toEqual({ received: true, result: "applied" }); + expect(b.sql.rows("SELECT plan, subscription_status FROM accounts")[0]).toEqual({ plan: "team", subscription_status: "trialing" }); + expect(await text(call(b.env, "GET", `/account/billing?project=${b.blog.id}`, { cookie: b.cookie }))).toContain("Continue with Pro"); + // it is cancelled: the account is on Free again, and no purchase starts another trial + const ended = accountSub("sub_ada0000001", b.account.id, "cus_ada0000001", "canceled"); + b.s.subs.set(ended.id, ended); + expect((await json(deliver(b.env, stripeEvent("customer.subscription.deleted", ended), b.s.fetch))).result).toBe("applied"); + const again = await checkout(b, b.blog.id); + expect(again.status).toBe(200); + expect((await again.json() as any).trial_days).toBeNull(); expect(sessionsOf(b.s)[1]!.has("subscription_data[trial_period_days]")).toBe(false); expect(sessionsOf(b.s)[1]!.get("allow_promotion_codes")).toBe("true"); - // the blog's plan page offers Pro, not a trial - const page = await text(call(b.env, "GET", `/account/billing?project=${b.blog.id}`, { cookie: b.cookie })); - expect(page).toContain('data-plan="team" data-pick>Start Pro for Blog'); + // the plan page offers Pro, not a trial + const page = await text(call(b.env, "GET", `/account/billing?project=${b.shop.id}`, { cookie: b.cookie })); + expect(page).toContain('data-plan="team" data-pick>Start Pro'); expect(page).not.toContain("free trial"); expect(page).not.toContain('class="trialnote"'); - // a cancelled subscription does not start another trial: the project keeps its subscription id - const ended = projectSub("sub_shop000001", b.account.id, "cus_ada0000001", b.shop.id, "canceled"); - b.s.subs.set(ended.id, ended); - expect((await json(deliver(b.env, stripeEvent("customer.subscription.deleted", ended), b.s.fetch))).result).toBe("applied"); - const again = await json(checkout(b, b.shop.id)); - expect(again.trial_days).toBeNull(); - expect(sessionsOf(b.s)[2]!.has("subscription_data[trial_period_days]")).toBe(false); log.mockRestore(); }); @@ -165,21 +346,19 @@ describe("Pro's trial: 3 days free at Checkout, once per account (src/billing.ts expect(sessionsOf(s)[0]!.get("subscription_data[trial_period_days]")).toBe("3"); }); - it("one trial per account: while one project's Checkout holds the trial, another project's starts without one; a page is reused only with the same trial", async () => { + it("one trial per account: a second app's page while the first's Checkout is open gets that same page; other seats close it for a new one", async () => { const b = await buyer(); const first = await checkout(b, b.shop.id); expect((await first.json() as any).trial_days).toBe(3); - expect(sessionsOf(b.s)[0]!.get("metadata[genclass_trial_days]")).toBe("3"); - // the shop's page is still open: the blog's starts without a trial + // the shop's page is still open: the blog's plan page gets the same Stripe page, so there is one trial to take const second = await checkout(b, b.blog.id); - expect((await second.json() as any).trial_days).toBeNull(); - const blogForm = sessionsOf(b.s)[1]!; - expect(blogForm.has("subscription_data[trial_period_days]")).toBe(false); - expect(blogForm.has("metadata[genclass_trial_days]")).toBe(false); - // the shop asked again: its open page, with the same trial, is the answer (no second session) - const again = await checkout(b, b.shop.id); - expect((await again.json() as any).trial_days).toBe(3); - expect(sessionsOf(b.s)).toHaveLength(2); + expect(await second.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000001", trial_days: 3 }); + expect(sessionsOf(b.s)).toHaveLength(1); + // asked for 2 developers: the open page is closed and one new page opened, still the account's one trial + const more = await checkout(b, b.blog.id, { seats: 2 }); + expect(await more.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000002", trial_days: 3 }); + expect(b.s.sessions.get("cs_test_000001")!.status).toBe("expired"); + expect([...b.s.sessions.values()].filter((x) => x.status === "open").map((x) => x.id)).toEqual(["cs_test_000002"]); }); it("trialFor: the plan's trial_days for an account with no subscription anywhere; 0 for a plan without one, a past subscription, or one on Stripe", async () => { @@ -194,12 +373,12 @@ describe("Pro's trial: 3 days free at Checkout, once per account (src/billing.ts // a plan with no trial, or a trial that is not a whole number of days expect(await trialFor(t.sql.api, a.account.id, FREE)).toBe(0); for (const trial_days of [0, -3, 1.5, undefined]) expect(await trialFor(t.sql.api, a.account.id, { ...pro, trial_days }), String(trial_days)).toBe(0); - // a project's subscription, cancelled or not + // a project's subscription (version 4), cancelled or not t.sql.db.prepare("UPDATE projects SET subscription_id = 'sub_p', subscription_status = 'canceled' WHERE id = ?").run(app.id); expect(await trialFor(t.sql.api, a.account.id, pro)).toBe(0); t.sql.db.prepare("UPDATE projects SET subscription_id = NULL, subscription_status = NULL WHERE id = ?").run(app.id); expect(await trialFor(t.sql.api, a.account.id, pro)).toBe(3); - // the account's own subscription from before plans were per project + // the account's own subscription setPlan(t.sql, a.account.id, "free", { subscription_id: "sub_old", subscription_status: "canceled" }); expect(await trialFor(t.sql.api, a.account.id, pro)).toBe(0); // another account's subscription is not this one's @@ -208,7 +387,7 @@ describe("Pro's trial: 3 days free at Checkout, once per account (src/billing.ts }); }); -describe("the webhook writes a project's subscription to that project and touches nothing else", () => { +describe("the webhook still writes a version-4 project's subscription to that project and touches nothing else", () => { it("two projects, two subscriptions: each project gets its plan, the account row stays, nothing is cancelled or refunded", async () => { const b = await buyer(); const log = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -227,14 +406,18 @@ describe("the webhook writes a project's subscription to that project and touche expect(b.sql.rows("SELECT plan, subscription_id FROM accounts")[0]).toEqual({ plan: "free", subscription_id: null }); // never a cancellation or a refund: each project is paid for on its own expect(b.s.calls.filter((c) => c.method === "DELETE" || c.url.pathname.startsWith("/v1/refunds") || /\/cancel$/.test(c.url.pathname))).toEqual([]); - // the project's access is Team: 90 days of history, visitor analytics + // the account has Pro: 90 days of history, visitor analytics const st = await json(call(b.env, "GET", `/api/account/projects/${b.shop.id}/stats?range=90d`, { cookie: b.cookie })); expect(st.range).toBe("90d"); expect(st.plan).toEqual({ id: "team", name: "Pro", source: "paid" }); expect(st.locked).toBeUndefined(); - // the subscription ends: the project goes back to Free + // one subscription ends: the other still gives the account Pro; both ended, Free again expect(await apply(projectSub("sub_shop000001", b.account.id, "cus_ada0000001", b.shop.id!, "canceled"), "customer.subscription.deleted")).toEqual({ received: true, result: "applied" }); expect(b.sql.rows("SELECT plan, subscription_status FROM projects WHERE name = 'Shop'")[0]).toEqual({ plan: null, subscription_status: "canceled" }); + expect((await json(call(b.env, "GET", `/api/account/projects/${b.shop.id}/stats?range=90d`, { cookie: b.cookie }))).range).toBe("90d"); + expect(await apply(projectSub("sub_blog000001", b.account.id, "cus_ada0000001", b.blog.id!, "canceled"), "customer.subscription.deleted")).toEqual({ received: true, result: "applied" }); + resetOwnerCache(); + expect((await json(call(b.env, "GET", `/api/account/projects/${b.shop.id}/stats?range=90d`, { cookie: b.cookie }))).range).toBe("7d"); log.mockRestore(); }); @@ -244,8 +427,7 @@ describe("the webhook writes a project's subscription to that project and touche const env = { ...b.env, NOTIFY_WEBHOOK: "https://hooks.example/notify" }; const s = fakeStripe({ answer: (url, init) => (url.hostname === "hooks.example" ? (told.push(String(init.body)), new Response("ok")) : undefined) }); s.genclassCustomer("cus_ada0000001", b.account.id); - const lines: string[] = []; - const log = vi.spyOn(console, "log").mockImplementation((...x: unknown[]) => void lines.push(x.join(" "))); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); const apply = async (sub: Record) => { s.subs.set(sub.id as string, sub); return json(deliver(env, stripeEvent("customer.subscription.created", sub), s.fetch)); @@ -272,41 +454,54 @@ describe("the webhook writes a project's subscription to that project and touche }); }); -describe("Free covers 3 projects; a fourth waits for a plan of its own", () => { - it("a fourth project is created, and its dashboard is locked with the way to its plan page", async () => { - const t = setup(LIVE); - const a = await signedIn(t.env); - const apps = []; - for (const n of ["a", "b", "c", "d"]) apps.push(await createApp(t.env, a.cookie, n)); - expect((await call(t.env, "GET", `/api/account/projects/${apps[2]!.id}/stats?range=7d`, { cookie: a.cookie })).status).toBe(200); - const locked = await call(t.env, "GET", `/api/account/projects/${apps[3]!.id}/stats?range=7d`, { cookie: a.cookie }); +describe("an app past what the plan covers waits on its plan page (Free: one app; Pro: 3)", () => { + it("a second app on Free is created, and its dashboard is locked with the way to its plan page; Pro opens it, and a 4th waits", async () => { + const t = await withApps(["a", "b", "c", "d"]); + const stats = (i: number) => call(t.env, "GET", `/api/account/projects/${t.apps[i]!.id}/stats?range=7d`, { cookie: t.cookie }); + expect((await stats(0)).status).toBe(200); + const locked = await stats(1); expect(locked.status).toBe(402); - expect(await locked.json()).toMatchObject({ locked: true, needs_plan: true, upgrade_url: `https://genclass.dev/account/billing?project=${apps[3]!.id}` }); - // once one of the first three is paid for, the fourth is one of the three on Free - t.sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_status = 'active' WHERE id = ?").run(apps[0]!.id); + expect(await locked.json()).toMatchObject({ locked: true, needs_plan: true, upgrade_url: `https://genclass.dev/account/billing?project=${t.apps[1]!.id}` }); + // the account on Pro: its first 3 apps open; the 4th is past them + paidPro(t.sql, t.account.id); resetOwnerCache(); - expect((await call(t.env, "GET", `/api/account/projects/${apps[3]!.id}/stats?range=7d`, { cookie: a.cookie })).status).toBe(200); + expect([(await stats(1)).status, (await stats(2)).status]).toEqual([200, 200]); + const past = await stats(3); + expect(past.status).toBe(402); + // past Pro's 3: the app's plan page, which says more apps are Enterprise's + const pj = await past.json(); + expect(pj).toMatchObject({ ok: false, locked: true, needs_plan: true, upgrade_url: `https://genclass.dev/account/billing?project=${t.apps[3]!.id}` }); + expect(pj.error).toContain("your plan covers 3 apps, and this one is past them: more apps come with Enterprise"); + // the page polling for a payment reads the lock + expect(await json(call(t.env, "GET", `/api/billing?project=${t.apps[3]!.id}`, { cookie: t.cookie }))).toEqual({ project: { id: t.apps[3]!.id, plan: "free", source: null, locked: "pro_apps" } }); + expect(await json(call(t.env, "GET", `/api/billing?project=${t.apps[0]!.id}`, { cookie: t.cookie }))).toEqual({ project: { id: t.apps[0]!.id, plan: "team", source: "paid", locked: null } }); }); - it("the billing page lists each project with its plan; the project's page shows Team large and Free small", async () => { - const t = setup(LIVE); - const a = await signedIn(t.env); - const apps = []; - for (const n of ["Shop", "Blog", "Docs", "Extra"]) apps.push(await createApp(t.env, a.cookie, n)); - const page = await text(call(t.env, "GET", "/account/billing", { cookie: a.cookie })); - expect(page).toContain('

    $15 / project / month

    '); - expect(page).toContain('Choose a project'); + it("the billing page lists each app with its plan and sells Pro for the account", async () => { + const t = await withApps(["Shop", "Blog"]); + const page = await text(call(t.env, "GET", "/account/billing", { cookie: t.cookie })); + expect(page).toContain('

    $15 / developer / month

    '); + expect(page).toContain('data-plan="team">Upgrade to Pro'); + expect(page).not.toContain("Choose a project"); expect(page).toContain('id="projects"'); - expect(page).toContain(`ShopFree`); - expect(page).toContain("Needs a plan"); - const one = await text(call(t.env, "GET", `/account/billing?project=${apps[0]!.id}&from=cli`, { cookie: a.cookie })); + expect(page).toContain("ShopFree"); + expect(page).toContain("BlogNeeds a plan"); + paidPro(t.sql, t.account.id); + const paid = await text(call(t.env, "GET", "/account/billing", { cookie: t.cookie })); + expect(paid).toContain("BlogPro"); + }); + + it("the plan page, Free and covered: Pro large with the trial, Free small with the reason popup", async () => { + const t = await withApps(["Shop", "Blog"]); + const one = await text(call(t.env, "GET", `/account/billing?project=${t.apps[0]!.id}&from=cli`, { cookie: t.cookie })); expect(one).toContain("

    Choose a plan for Shop

    "); expect(one).toContain("Shop is on Free."); - expect(one).toContain('

    Pro

    $15 / project / month

    '); + expect(one).toContain('

    Pro

    $15 / developer / month

    '); // an account that has never had a subscription starts with Pro's 3 days free (billing.ts trialFor) - expect(one).toContain('data-plan="team" data-pick>Start your 3-day free trial'); - expect(one).toContain('

    3 days free, then $15 / project / month for Shop. Cancel before the trial ends and you pay nothing.

    '); - // Free is the small way on, a very small button under the big card + expect(one).toContain(''); + expect(one).toContain('

    3 days free, then $15 / developer / month. Cancel before the trial ends and you pay nothing.

    '); + expect(one).not.toContain('type="button" data-continue>'); + expect(one).not.toContain('Your plan'); // Free is the small way on: a very small link under the big card, and a popup that asks why before it goes on expect(one).toContain('

    Try for free (limited)

    '); expect(one).toContain(".linkish{background:none;border:0;padding:0;font:inherit;font-size:11px"); @@ -314,71 +509,188 @@ describe("Free covers 3 projects; a fourth waits for a plan of its own", () => { for (const [val, words] of FREE_REASONS) expect(one).toContain(``); expect(one).toContain(''); expect(one).toContain("Start Pro instead"); - // it never sends anyone back to a terminal: the choice is made here - expect(one.toLowerCase()).not.toContain("terminal"); + // the Checkout is bought for the account, from this page (it comes back here) + expect(one).toContain('gcPost("/api/billing/checkout", { plan: b.getAttribute("data-plan"), project_id: cfg.project })'); + // it never sends anyone back to a terminal: the choice is made here (Pro's card lists the command as a feature) + expect(one).toContain("
  • The genclass command in your terminal
  • "); + expect(one.replace("
  • The genclass command in your terminal
  • ", "").toLowerCase()).not.toContain("terminal"); expect(one).toContain("Shop is in your account. Choose its plan to go on."); - // the fourth: no free way on - const fourth = await text(call(t.env, "GET", `/account/billing?project=${apps[3]!.id}`, { cookie: a.cookie })); - expect(fourth).toContain("needs a plan: Free covers 3 projects"); - expect(fourth).not.toContain("Try for free"); + // ?start=1 (the genclass command) opens the trial's Checkout at once + expect(await text(call(t.env, "GET", `/account/billing?project=${t.apps[0]!.id}&start=1`, { cookie: t.cookie }))).toContain('"go":true'); // signed out: through the sign-in, back to the same page - const out = await call(t.env, "GET", `/account/billing?project=${apps[0]!.id}&start=1&from=cli`, {}); + const out = await call(t.env, "GET", `/account/billing?project=${t.apps[0]!.id}&start=1&from=cli`, {}); expect(out.status).toBe(302); - expect(out.headers.get("location")).toBe(`/login?next=${encodeURIComponent(`/account/billing?project=${apps[0]!.id}&start=1&from=cli`)}`); + expect(out.headers.get("location")).toBe(`/login?next=${encodeURIComponent(`/account/billing?project=${t.apps[0]!.id}&start=1&from=cli`)}`); // the page waits for its payment with this - expect(await json(call(t.env, "GET", `/api/billing?project=${apps[0]!.id}`, { cookie: a.cookie }))).toEqual({ project: { id: apps[0]!.id, plan: "free", source: null, locked: null } }); + expect(await json(call(t.env, "GET", `/api/billing?project=${t.apps[0]!.id}`, { cookie: t.cookie }))).toEqual({ project: { id: t.apps[0]!.id, plan: "free", source: null, locked: null } }); }); - it("Try for free (limited) records the choice for that app only; the fourth cannot, and it is a browser write", async () => { - const t = setup(LIVE); - const a = await signedIn(t.env); - const apps = []; - for (const n of ["Shop", "Blog", "Docs", "Extra"]) apps.push(await createApp(t.env, a.cookie, n)); - const chosen = (id: string) => t.sql.db.prepare("SELECT plan_chosen FROM projects WHERE id = ?").get(id) as { plan_chosen: string | null }; - expect(chosen(apps[0]!.id).plan_chosen).toBeNull(); + it("the plan page, Free and locked (an app past Free's one): the trial, and no free way on", async () => { + const t = await withApps(["Shop", "Blog"]); + const page = await text(call(t.env, "GET", `/account/billing?project=${t.apps[1]!.id}`, { cookie: t.cookie })); + expect(page).toContain("

    Choose a plan for Blog

    "); + expect(page).toContain("Free covers one app, and Blog is one more: it needs Pro."); + expect(page).toContain('data-plan="team" data-pick>Start your 3-day free trial'); + expect(page).not.toContain("Try for free"); + expect(page).not.toContain('id="why"'); + expect(page).not.toContain('type="button" data-continue>'); + }); + + it("the plan page, covered by a paid plan or a grant: the big Pro card says Your plan, and Continue with Pro", async () => { + const t = await withApps(["Shop", "Blog"]); + paidPro(t.sql, t.account.id); + const page = await text(call(t.env, "GET", `/account/billing?project=${t.apps[1]!.id}&new=1`, { cookie: t.cookie })); + // every new app sees its plan page, a paying account's too + expect(page).toContain("

    Choose a plan for Blog

    "); + expect(page).toContain("Your Pro plan covers Blog"); + expect(page).toContain('

    Pro Your plan

    '); + expect(page).toContain(''); + expect(page).toContain('Manage billing'); + expect(page).not.toContain("data-pick"); + expect(page).not.toContain("free trial"); + expect(page).not.toContain("Try for free"); + // the button goes on with the plan, then to the app (its setup for a new one) + expect(page).toContain('gcPost("/api/billing/projects/" + encodeURIComponent(cfg.project) + "/continue", {})'); + expect(page).toContain('"isNew":true'); + // ?start=1 never opens a Checkout for an app the plan covers + expect(await text(call(t.env, "GET", `/account/billing?project=${t.apps[1]!.id}&start=1`, { cookie: t.cookie }))).toContain('"go":false'); + // a grant: the same, set up by GenClass, with no billing to manage + const g = await withApps(["Docs"]); + setPlan(g.sql, g.account.id, "free", { comp_until: "9999-12-31T00:00:00.000Z" }); + const granted = await text(call(g.env, "GET", `/account/billing?project=${g.apps[0]!.id}`, { cookie: g.cookie })); + expect(granted).toContain("Your Enterprise plan, set up by GenClass, covers Docs."); + expect(granted).toContain('Your plan'); + expect(granted).toContain("Continue with Enterprise"); + expect(granted).not.toContain('id="portal"'); + expect(granted).not.toContain("data-pick"); + }); + + it("the plan page, an app past Pro's 3: Email us about Enterprise, nothing to buy, no waiting for a payment", async () => { + const t = await withApps(["Shop", "Blog", "Docs", "Extra"]); + paidPro(t.sql, t.account.id); + const page = await text(call(t.env, "GET", `/account/billing?project=${t.apps[3]!.id}&start=1`, { cookie: t.cookie })); + expect(page).toContain("

    Choose a plan for Extra

    "); + expect(page).toContain("Your Pro plan covers up to 3 apps, and Extra is one more. More apps come with Enterprise, agreed by email."); + expect(page).toContain(`Email us about Enterprise`); + expect(page).toContain("Or remove an app you no longer use, and this one takes its place."); + expect(page).not.toContain('type="button" data-addapps>'); + expect(page).not.toContain('type="button" data-continue>'); + expect(page).not.toContain("data-pick"); + expect(page).not.toContain("Try for free"); + // nothing is bought here, so the page neither opens a Checkout nor polls for one + expect(page).toContain('"go":false,"wait":false'); + }); + + it("Try for free (limited) records the choice for that app only; an app past Free's one cannot, and it is a browser write", async () => { + const t = await withApps(["Shop", "Blog", "Docs", "Extra"]); + const apps = t.apps; + expect(chosenAt(t.sql, apps[0]!.id)).toBeNull(); // nobody goes on with Free without saying why: a reason from the list is needed - expect((await call(t.env, "POST", `/api/billing/projects/${apps[0]!.id}/free`, { cookie: a.cookie, body: {} })).status).toBe(400); - expect((await call(t.env, "POST", `/api/billing/projects/${apps[0]!.id}/free`, { cookie: a.cookie, body: { reason: "because" } })).status).toBe(400); - expect(chosen(apps[0]!.id).plan_chosen).toBeNull(); + expect((await call(t.env, "POST", `/api/billing/projects/${apps[0]!.id}/free`, { cookie: t.cookie, body: {} })).status).toBe(400); + expect((await call(t.env, "POST", `/api/billing/projects/${apps[0]!.id}/free`, { cookie: t.cookie, body: { reason: "because" } })).status).toBe(400); + expect(chosenAt(t.sql, apps[0]!.id)).toBeNull(); const told: string[] = []; const hook = (async (u: string | URL | Request, init?: RequestInit) => (String(u).startsWith("https://hooks.example/") ? (told.push(String(init?.body)), new Response("ok")) : new Response("no", { status: 500 }))) as never; const env = { ...t.env, NOTIFY_WEBHOOK: "https://hooks.example/notify" }; - const r = await call(env, "POST", `/api/billing/projects/${apps[0]!.id}/free`, { cookie: a.cookie, body: { reason: "price", note: " too much\u0007 for a side project " }, fetch: hook }); + const r = await call(env, "POST", `/api/billing/projects/${apps[0]!.id}/free`, { cookie: t.cookie, body: { reason: "price", note: " too much\u0007 for a side project " }, fetch: hook }); expect(r.status).toBe(200); - expect(t.sql.rows("SELECT account_id, project_id, reason, note FROM plan_feedback")).toEqual([{ account_id: a.account.id, project_id: apps[0]!.id, reason: "price", note: "too much for a side project" }]); + expect(t.sql.rows("SELECT account_id, project_id, reason, note FROM plan_feedback")).toEqual([{ account_id: t.account.id, project_id: apps[0]!.id, reason: "price", note: "too much for a side project" }]); expect(told).toHaveLength(1); expect(JSON.parse(told[0]!)).toMatchObject({ kind: "free_chosen", reason: "price", project: apps[0]!.id, text: 'Free chosen for "Shop" (ada@example.com): It costs too much. "too much for a side project"' }); expect(await r.json()).toEqual({ ok: true, plan: "free" }); - expect(chosen(apps[0]!.id).plan_chosen).toMatch(/^20\d\d-\d\d-\d\dT/); - expect(chosen(apps[1]!.id).plan_chosen).toBeNull(); - // the fourth has only the paid plan - expect((await call(t.env, "POST", `/api/billing/projects/${apps[3]!.id}/free`, { cookie: a.cookie, body: { reason: "trying" } })).status).toBe(409); - expect(chosen(apps[3]!.id).plan_chosen).toBeNull(); + expect(chosenAt(t.sql, apps[0]!.id)).toMatch(/^20\d\d-\d\d-\d\dT/); + expect(chosenAt(t.sql, apps[1]!.id)).toBeNull(); + // the others have only the paid plan + for (const i of [1, 3]) expect((await call(t.env, "POST", `/api/billing/projects/${apps[i]!.id}/free`, { cookie: t.cookie, body: { reason: "trying" } })).status).toBe(409); + expect(chosenAt(t.sql, apps[3]!.id)).toBeNull(); // another account's app, and no app at all const b = await signedIn(t.env, "bob@example.com"); expect((await call(t.env, "POST", `/api/billing/projects/${apps[1]!.id}/free`, { cookie: b.cookie, body: { reason: "trying" } })).status).toBe(404); - expect(chosen(apps[1]!.id).plan_chosen).toBeNull(); + expect(chosenAt(t.sql, apps[1]!.id)).toBeNull(); // signed out, and a GET expect((await call(t.env, "POST", `/api/billing/projects/${apps[1]!.id}/free`, { body: { reason: "trying" } })).status).toBe(401); - expect((await call(t.env, "GET", `/api/billing/projects/${apps[1]!.id}/free`, { cookie: a.cookie })).status).toBe(405); + expect((await call(t.env, "GET", `/api/billing/projects/${apps[1]!.id}/free`, { cookie: t.cookie })).status).toBe(405); expect(t.sql.rows("SELECT COUNT(*) AS n FROM plan_feedback")).toEqual([{ n: 1 }]); // the popup lists exactly the reasons the route takes expect(FREE_REASONS_SHOWN).toEqual(FREE_REASONS); }); - it("the website: an app on Free that has not chosen opens its plan page instead of its dashboard, until it has", async () => { - const t = setup(LIVE); - const a = await signedIn(t.env); - const shop = await createApp(t.env, a.cookie, "Shop"); - const r = await call(t.env, "GET", `/account/apps/${shop.id}`, { cookie: a.cookie }); + it("a project that is paid for is not deleted while it bills", async () => { + const t = await withApps(["Shop"]); + const app = t.apps[0]!; + t.sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_id = 'sub_s', subscription_status = 'active' WHERE id = ?").run(app.id); + const r = await call(t.env, "DELETE", `/api/account/projects/${app.id}`, { body: { confirm: "delete" }, cookie: t.cookie }); + expect(r.status).toBe(409); + expect((await r.json() as any).error).toContain("cancel it in Manage billing"); + t.sql.db.prepare("UPDATE projects SET subscription_status = 'canceled', plan = NULL WHERE id = ?").run(app.id); + expect((await call(t.env, "DELETE", `/api/account/projects/${app.id}`, { body: { confirm: "delete" }, cookie: t.cookie })).status).toBe(204); + }); +}); + +describe("POST /api/billing/projects//continue: an app the account's plan covers goes on with it", () => { + it("writes when the plan was chosen, once; answers with the plan", async () => { + const t = await withApps(["Shop", "Blog"]); + paidPro(t.sql, t.account.id); + const r = await call(t.env, "POST", `/api/billing/projects/${t.apps[1]!.id}/continue`, { cookie: t.cookie, body: {} }); + expect(r.status).toBe(200); + expect(await r.json()).toEqual({ ok: true, plan: "team" }); + const at = chosenAt(t.sql, t.apps[1]!.id); + expect(at).toBe(now().toISOString()); + expect(chosenAt(t.sql, t.apps[0]!.id)).toBeNull(); + // again, later: the first time stays + const { advance } = await import("./account-helpers.js"); + advance(60_000); + expect(await json(call(t.env, "POST", `/api/billing/projects/${t.apps[1]!.id}/continue`, { cookie: t.cookie, body: {} }))).toEqual({ ok: true, plan: "team" }); + expect(chosenAt(t.sql, t.apps[1]!.id)).toBe(at); + // no feedback row and no notice: this is not Free chosen + expect(t.sql.rows("SELECT COUNT(*) AS n FROM plan_feedback")).toEqual([{ n: 0 }]); + }); + + it("a grant, a version-4 project subscription and PLAN_GATES open are plans that cover an app", async () => { + const g = await withApps(["Shop"]); + setPlan(g.sql, g.account.id, "free", { comp_until: "9999-12-31T00:00:00.000Z" }); + expect(await json(call(g.env, "POST", `/api/billing/projects/${g.apps[0]!.id}/continue`, { cookie: g.cookie, body: {} }))).toEqual({ ok: true, plan: "enterprise" }); + const v4 = await withApps(["Shop", "Blog"]); + projectPaid(v4.sql, v4.apps[0]!.id); + expect(await json(call(v4.env, "POST", `/api/billing/projects/${v4.apps[1]!.id}/continue`, { cookie: v4.cookie, body: {} }))).toEqual({ ok: true, plan: "team" }); + const open = await withApps(["Shop"], { PLAN_GATES: "open" }); + expect(await json(call(open.env, "POST", `/api/billing/projects/${open.apps[0]!.id}/continue`, { cookie: open.cookie, body: {} }))).toEqual({ ok: true, plan: "enterprise" }); + }); + + it("409 for an app the plan does not cover (Free, or past Pro's 3); 404 for another account's; a browser write only", async () => { + const t = await withApps(["Shop", "Blog", "Docs", "Extra"]); + const go = (i: number, o: Record = {}) => call(t.env, "POST", `/api/billing/projects/${t.apps[i]!.id}/continue`, { cookie: t.cookie, body: {}, ...o }); + // on Free nothing covers an app to go on with: Free is chosen with its reason (/free), or Pro is bought + for (const i of [0, 1]) { + const r = await go(i); + expect(r.status, String(i)).toBe(409); + expect((await r.json() as any).error).toBe(`${t.apps[i]!.name} needs a plan first`); + } + paidPro(t.sql, t.account.id); + expect((await go(2)).status).toBe(200); + expect((await go(3)).status).toBe(409); + expect(chosenAt(t.sql, t.apps[3]!.id)).toBeNull(); + const bob = await signedIn(t.env, "bob@example.com"); + expect((await go(0, { cookie: bob.cookie })).status).toBe(404); + expect((await call(t.env, "POST", "/api/billing/projects/0000000000000000/continue", { cookie: t.cookie, body: {} })).status).toBe(404); + expect((await go(0, { cookie: undefined })).status).toBe(401); + expect((await go(0, { origin: "https://evil.example" })).status).toBe(403); + const { cliToken } = await import("./account-helpers.js"); + expect((await go(0, { cookie: undefined, bearer: await cliToken(t.env, t.cookie), origin: null })).status).toBe(403); + expect((await call(t.env, "GET", `/api/billing/projects/${t.apps[0]!.id}/continue`, { cookie: t.cookie })).status).toBe(405); + expect(chosenAt(t.sql, t.apps[0]!.id)).toBeNull(); + }); +}); + +describe("the website: an own app opens its plan page until a plan is chosen there, and while the plan does not cover it", () => { + it("an app on Free that has not chosen opens its plan page instead of its dashboard, until it has", async () => { + const t = await withApps(["Shop"]); + const shop = t.apps[0]!; + const r = await call(t.env, "GET", `/account/apps/${shop.id}`, { cookie: t.cookie }); expect(r.status).toBe(302); expect(r.headers.get("location")).toBe(`/account/billing?project=${shop.id}`); - await call(t.env, "POST", `/api/billing/projects/${shop.id}/free`, { cookie: a.cookie, body: { reason: "unsure" } }); - expect((await call(t.env, "GET", `/account/apps/${shop.id}`, { cookie: a.cookie })).status).toBe(200); - // on Team: never sent there - const blog = await createApp(t.env, a.cookie, "Blog"); - t.sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_id = 'sub_b', subscription_status = 'active' WHERE id = ?").run(blog.id); - expect((await call(t.env, "GET", `/account/apps/${blog.id}`, { cookie: a.cookie })).status).toBe(200); + await call(t.env, "POST", `/api/billing/projects/${shop.id}/free`, { cookie: t.cookie, body: { reason: "unsure" } }); + expect((await call(t.env, "GET", `/account/apps/${shop.id}`, { cookie: t.cookie })).status).toBe(200); // where nothing is sold, nothing to choose const none = setup(); const n = await signedIn(none.env); @@ -386,36 +698,56 @@ describe("Free covers 3 projects; a fourth waits for a plan of its own", () => { expect((await call(none.env, "GET", `/account/apps/${app.id}`, { cookie: n.cookie })).status).toBe(200); }); - it("an older plan link (?plan=team&start=1, from the pricing page or an older genclass command) opens the first app on Free", async () => { - const t = setup(LIVE); - const a = await signedIn(t.env); - // no app yet: the trial button goes to the new app form first (its plan page follows); a plain link shows the page - const none = await call(t.env, "GET", "/account/billing?plan=team&start=1", { cookie: a.cookie }); - expect(none.status).toBe(302); - expect(none.headers.get("location")).toBe("/account/new"); - const page = await call(t.env, "GET", "/account/billing?plan=team", { cookie: a.cookie }); - expect(page.status).toBe(200); - expect(await page.text()).toContain("Add a project first"); - const shop = await createApp(t.env, a.cookie, "Shop"); - const blog = await createApp(t.env, a.cookie, "Blog"); - const r = await call(t.env, "GET", "/account/billing?plan=team&start=1", { cookie: a.cookie }); - expect(r.status).toBe(302); - expect(r.headers.get("location")).toBe(`/account/billing?project=${shop.id}&from=cli`); - // Shop on Team: Blog is the one - t.sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_id = 'sub_s', subscription_status = 'active' WHERE id = ?").run(shop.id); - expect((await call(t.env, "GET", "/account/billing?plan=team", { cookie: a.cookie })).headers.get("location")).toBe(`/account/billing?project=${blog.id}`); + it("an app of a paying account, or on a grant, that has not chosen opens its plan page too, until Continue with Pro", async () => { + const t = await withApps(["Shop", "Blog"]); + paidPro(t.sql, t.account.id); + for (const app of t.apps) expect((await call(t.env, "GET", `/account/apps/${app.id}`, { cookie: t.cookie })).headers.get("location")).toBe(`/account/billing?project=${app.id}`); + expect((await call(t.env, "POST", `/api/billing/projects/${t.apps[1]!.id}/continue`, { cookie: t.cookie, body: {} })).status).toBe(200); + expect((await call(t.env, "GET", `/account/apps/${t.apps[1]!.id}`, { cookie: t.cookie })).status).toBe(200); + expect((await call(t.env, "GET", `/account/apps/${t.apps[0]!.id}`, { cookie: t.cookie })).status).toBe(302); + // a version-4 project subscription + const v4 = await withApps(["Shop"]); + projectPaid(v4.sql, v4.apps[0]!.id); + expect((await call(v4.env, "GET", `/account/apps/${v4.apps[0]!.id}`, { cookie: v4.cookie })).status).toBe(302); + // GenClass's grant + const g = await withApps(["Shop"]); + setPlan(g.sql, g.account.id, "free", { comp_until: "9999-12-31T00:00:00.000Z" }); + expect((await call(g.env, "GET", `/account/apps/${g.apps[0]!.id}`, { cookie: g.cookie })).status).toBe(302); + expect((await call(g.env, "POST", `/api/billing/projects/${g.apps[0]!.id}/continue`, { cookie: g.cookie, body: {} })).status).toBe(200); + expect((await call(g.env, "GET", `/account/apps/${g.apps[0]!.id}`, { cookie: g.cookie })).status).toBe(200); }); - it("a project that is paid for is not deleted while it bills", async () => { + it("a locked app opens its plan page even once a plan was chosen for it, until the plan covers it", async () => { + const t = await withApps(["Shop", "Blog", "Docs", "Extra"]); + for (const app of t.apps) t.sql.db.prepare("UPDATE projects SET plan_chosen = ? WHERE id = ?").run(now().toISOString(), app.id); + const at = async (i: number) => (await call(t.env, "GET", `/account/apps/${t.apps[i]!.id}`, { cookie: t.cookie })).status; + // Free: the first opens, the rest wait for Pro + expect([await at(0), await at(1)]).toEqual([200, 302]); + // Pro: 3 open, the 4th waits for Enterprise + paidPro(t.sql, t.account.id); + expect([await at(0), await at(1), await at(2), await at(3)]).toEqual([200, 200, 200, 302]); + expect((await call(t.env, "GET", `/account/apps/${t.apps[3]!.id}`, { cookie: t.cookie })).headers.get("location")).toBe(`/account/billing?project=${t.apps[3]!.id}`); + // one of the 3 removed: the 4th takes its place + expect((await call(t.env, "DELETE", `/api/account/projects/${t.apps[1]!.id}`, { body: { confirm: "delete" }, cookie: t.cookie })).status).toBe(204); + expect(await at(3)).toBe(200); + }); + + it("?plan=team&start=1 (the pricing page, the genclass command's checkout_url) opens the account's billing page, which starts Pro's Checkout", async () => { const t = setup(LIVE); const a = await signedIn(t.env); - const app = await createApp(t.env, a.cookie, "Shop"); - t.sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_id = 'sub_s', subscription_status = 'active' WHERE id = ?").run(app.id); - const r = await call(t.env, "DELETE", `/api/account/projects/${app.id}`, { body: { confirm: "delete" }, cookie: a.cookie }); - expect(r.status).toBe(409); - expect((await r.json() as any).error).toContain("cancel it in Manage billing"); - t.sql.db.prepare("UPDATE projects SET subscription_status = 'canceled', plan = NULL WHERE id = ?").run(app.id); - expect((await call(t.env, "DELETE", `/api/account/projects/${app.id}`, { body: { confirm: "delete" }, cookie: a.cookie })).status).toBe(204); + // no app needed: Pro is the account's + const none = await call(t.env, "GET", "/account/billing?plan=team&start=1", { cookie: a.cookie }); + expect(none.status).toBe(200); + const html = await none.text(); + expect(html).toContain('"go":true'); + expect(html).toContain('data-plan="team" data-pick>Upgrade to Pro'); + await createApp(t.env, a.cookie, "Shop"); + expect((await call(t.env, "GET", "/account/billing?plan=team&start=1", { cookie: a.cookie })).status).toBe(200); + // a plain link shows the page, and starts nothing + const plain = await text(call(t.env, "GET", "/account/billing?plan=team", { cookie: a.cookie })); + expect(plain).not.toContain('"go"'); + // signed out: the plan survives the sign-in + expect((await call(t.env, "GET", "/account/billing?plan=team&start=1", {})).headers.get("location")).toBe("/login?next=%2Faccount%2Fbilling%3Fplan%3Dteam%26start%3D1"); }); }); @@ -423,7 +755,7 @@ describe("problems fixed live: 100 a month per project on Free, then observe onl /** A page load in guard mode in which the runtime fixed `n` problems. */ const fixes = (n: number) => Array.from({ length: n }, (_, i) => ({ t: "action", seq: i + 1, action: "coalesce", outcome: "applied" })); - it("past the limit, ingest's reply tells the runtime to observe only; the batch is stored and counted the same; Team lifts it", async () => { + it("past the limit, ingest's reply tells the runtime to observe only; the batch is stored and counted the same; Pro lifts it", async () => { const t = setup(LIVE); const a = await signedIn(t.env); const app = await createApp(t.env, a.cookie, "Shop"); @@ -439,8 +771,8 @@ describe("problems fixed live: 100 a month per project on Free, then observe onl const st = await json(call(t.env, "GET", `/api/account/projects/${app.id}/stats?range=7d`, { cookie: a.cookie })); expect(st.usage).toEqual({ used: 120, limit: 100, observe_only: true, resets: monthOf(new Date(st.generated)).resets }); expect(st.plan_url).toBe(`https://genclass.dev/account/billing?project=${app.id}`); - // the project on Team: no limit, no header - t.sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_status = 'active' WHERE id = ?").run(app.id); + // the account on Pro: no limit, no header + paidPro(t.sql, a.account.id); resetOwnerCache(); resetUsageCache(); const paid = await sendBatch(t.env, app.token, [{ t: "session", host: "shop.example", route: "/" }], { sid: "dddddddddddd" }); @@ -448,21 +780,22 @@ describe("problems fixed live: 100 a month per project on Free, then observe onl expect((await json(call(t.env, "GET", `/api/account/projects/${app.id}/stats?range=7d`, { cookie: a.cookie }))).usage).toMatchObject({ limit: null, observe_only: false }); }); - it("the CLI's overview names each project's plan and its page; its stats carry the usage", async () => { - const t = setup(LIVE); - const a = await signedIn(t.env); - for (const n of ["a", "b", "c", "d"]) await createApp(t.env, a.cookie, n); + it("the CLI's overview names each app's plan and its page, and which wait for one (Free's or Pro's)", async () => { + const t = await withApps(["a", "b", "c", "d"]); const { cliToken } = await import("./account-helpers.js"); - const bearer = await cliToken(t.env, a.cookie); + const bearer = await cliToken(t.env, t.cookie); + const overview = async () => (await json(call(t.env, "GET", "/api/cli/overview", { bearer, origin: null }))).projects.map((p: any) => [p.name, p.plan.id, p.plan.source, p.needs_plan]); + expect(await overview()).toEqual([["a", "free", null, false], ["b", "free", null, true], ["c", "free", null, true], ["d", "free", null, true]]); const o = await json(call(t.env, "GET", "/api/cli/overview", { bearer, origin: null })); - expect(o.projects.map((p: any) => [p.name, p.plan.id, p.needs_plan])).toEqual([["a", "free", false], ["b", "free", false], ["c", "free", false], ["d", "free", true]]); expect(o.projects[0].plan_url).toBe(`https://genclass.dev/account/billing?project=${o.projects[0].id}`); + // Pro: the 4th is past its 3 apps + paidPro(t.sql, t.account.id); + expect(await overview()).toEqual([["a", "team", "paid", false], ["b", "team", "paid", false], ["c", "team", "paid", false], ["d", "free", null, true]]); }); }); it("FREE is the first plan", () => expect(FREE.id).toBe("free")); -it("price() still names Team's price", () => expect(price("genclass_team_monthly").id).toBe("price_team000001")); -it("setPlan is available to the tests", () => expect(typeof setPlan).toBe("function")); +it("price() still names Pro's price", () => expect(price("genclass_team_monthly").id).toBe("price_team000001")); describe("Pro's 3-day trial (plans.json trial_days), on an account's first subscription only (from #227)", () => { it("a customer with no subscription gets 3 free days; one that has had any (even cancelled) gets none", async () => { @@ -474,8 +807,8 @@ describe("Pro's 3-day trial (plans.json trial_days), on an account's first subsc expect(first.form!.get("metadata[genclass_trial_days]")).toBe("3"); // a card is still taken up front expect(first.form!.get("payment_method_collection")).toBe("always"); - // the same customer once had a subscription for the other project, since cancelled - b.s.subs.set("sub_old0000001", projectSub("sub_old0000001", b.account.id, "cus_ada0000001", b.shop.id, "canceled")); + // the same customer once had a subscription, since cancelled + b.s.subs.set("sub_old0000001", accountSub("sub_old0000001", b.account.id, "cus_ada0000001", "canceled")); expect((await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team", project_id: b.blog.id }, cookie: b.cookie, fetch: b.s.fetch })).status).toBe(200); const later = b.s.calls.filter((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions").pop()!; expect(later.form!.has("subscription_data[trial_period_days]")).toBe(false); diff --git a/telemetry-worker/test/team.test.ts b/telemetry-worker/test/team.test.ts index 075ca9d7..c24d2d27 100644 --- a/telemetry-worker/test/team.test.ts +++ b/telemetry-worker/test/team.test.ts @@ -188,6 +188,10 @@ describe("a member reads the owner's dashboards with the owner's plan, and chang expect(html).not.toContain('type="button" data-mklink'); expect(html).toContain("The app's owner makes it."); expect(html).toContain('"member":true'); + // the owner's own app opens its plan page until the owner goes on with the plan there (plans.json version 5: + // every new app sees it, a paying account included); a member is never sent there + expect((await call(env, "GET", `/account/apps/${app.id}`, { cookie: owner.cookie })).headers.get("location")).toBe(`/account/billing?project=${app.id}`); + expect(await json(call(env, "POST", `/api/billing/projects/${app.id}/continue`, { cookie: owner.cookie, body: {} }))).toEqual({ ok: true, plan: "team" }); const ownerPage = await text(call(env, "GET", `/account/apps/${app.id}`, { cookie: owner.cookie })); expect(ownerPage).toContain('id="delproj"'); expect(ownerPage).toContain('type="button" data-mklink'); From 5e1d6cbdd0bcb179d6e05c72fa24a2d5516349ef Mon Sep 17 00:00:00 2001 From: Mehar Khanna Date: Fri, 13 Nov 2026 00:26:16 -0500 Subject: [PATCH 5/5] A new account's first screen is Pro's 3-day trial with a tiny Skip; Free on a plan page is a tiny Skip The owner, 2026-11-13: "when someone clicks free and gets started, we still give them the 3-day free trial and show it on their screen and then have them click a tiny button that says skip ... our entire goal is for them to put their credit cards in." - /account for an account with no app, on Free, where Pro is sold: "Start your 3-day free trial" large (the account's Checkout with the trial) and a tiny Skip on to its first app. Phone width checked. - An app's plan page: "Try for free (limited)" is now "Skip"; the reason popup still comes first. - genclass dashboard and its views: a 402 needs_pro prints the paywall's line (and needs_pro in --json), as every other command does (review of #230). - Tests: collector 842, CLI 188. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/app.mjs | 5 +++ packages/cli/test/server-account.test.ts | 14 ++++++++ telemetry-worker/README.md | 8 +++-- telemetry-worker/src/account-pages.ts | 22 +++++++++++- telemetry-worker/src/auth.ts | 16 ++++++++- telemetry-worker/src/billing-pages.ts | 4 +-- telemetry-worker/test/project-plans.test.ts | 37 ++++++++++++++++++++- 7 files changed, 99 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs index 0cf99354..639535b4 100644 --- a/packages/cli/src/app.mjs +++ b/packages/cli/src/app.mjs @@ -301,6 +301,11 @@ export async function appView(o = {}, deps = {}, view) { state = await readState({ cwd: o.cwd }, deps); app = await fetchApp(again, target.id, range, deps); } + // the genclass command is Pro's: an account without it is told where Pro starts, as every command says it + if (!app.ok && app.needsPro) { + if (!o.json) header({ app: target.name, framework: state.app.framework, account: state.account?.label, plan: planName(state, target.token), host: state.host }); + return (await import("./session.mjs")).refused(app, { what: "The dashboard", command: view.name, json: o.json }); + } if (!app.ok) { if (o.json) out(JSON.stringify({ ok: false, error: app.reauth ? "sign in again" : app.reason, run: app.reauth ? "npx genclass login" : null, dashboard_url: target.dashboard_url })); else { diff --git a/packages/cli/test/server-account.test.ts b/packages/cli/test/server-account.test.ts index 5aefc45a..ba125f89 100644 --- a/packages/cli/test/server-account.test.ts +++ b/packages/cli/test/server-account.test.ts @@ -214,6 +214,20 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { }); }); +describe("genclass is Pro's: a view the server refuses with 402 says where Pro starts (src/app.mjs, src/session.mjs refused)", () => { + it("Free: the dashboard's views print the paywall's line, and --json says needs_pro with the checkout", async () => { + const { env } = setup(LIVE); + const me = await account(env); + const v = await said(() => analytics(me.o, me.deps)); + expect(v.code).toBe(1); + expect(tooWide(v.text)).toEqual([]); + expect(flat(v.text)).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${HOST}/account/billing?plan=team&start=1`); + expect(flat(v.text)).toContain("npx genclass upgrade"); + expect(v.text).not.toContain("Could not read the dashboard"); + expect(JSON.parse((await said(() => analytics({ ...me.o, json: true }, me.deps))).text)).toEqual({ ok: false, error: "the genclass command comes with Pro", needs_pro: true, checkout_url: `${HOST}/account/billing?plan=team&start=1`, trial_days: 3, run: "npx genclass upgrade" }); + }); +}); + describe("genclass apps and team: the small words (src/manage.mjs)", () => { it("the hint names the app that heard from its site last; a plan with no seat count says person or people", async () => { const { env, sql } = setup(LIVE); diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md index 89b41c0a..bbf48d54 100644 --- a/telemetry-worker/README.md +++ b/telemetry-worker/README.md @@ -547,7 +547,11 @@ route but the overview and the account, which carry `cli` (`src/cli.ts` cliPaid) session, where nothing is sold to the account (no Stripe key, a test key and an account not listed) or with PLAN_GATES open. Free's list (the owner: "1 app connection, limited dashboard, open-source basics", and "keep them free"): one app, the totals for 7 days, the open-source runtime and model, the coding-agent loop, self-hosting, the -GitHub App on public repositories, the page check, 100 live fixes a month. +GitHub App on public repositories, the page check, 100 live fixes a month. A new account's first screen +(`/account` with no app, on Free, where Pro is sold) is Pro's trial: "Start your 3-day free trial" large (the +account's Checkout, `/account/billing?plan=team&start=1`) and a tiny "Skip" on to its first app (the owner: "our +entire goal is for them to put their credit cards in"). On an app's plan page Free is the same tiny "Skip", and +the reason popup still comes before it. The plans live in [`billing/plans.json`](../billing/plans.json) (version 3, 2026-11-11, the owner: "cut back on the free features"; one thing stays free, finding, seeing and fixing the problems): @@ -832,7 +836,7 @@ does about it. | `GET /api/billing` | browser session: `{ plan, subscription_status, current_period_end, cancel_at_period_end, on_sale, access, seats }` (`on_sale`: a plan is sold and this account can buy it); `access` is `paid`, `comp` (GenClass's grant), `open` (this server's `PLAN_GATES`) or null (Free); `seats`: the people the account holds, null for no limit | | `POST /api/billing/trial` | 410: GenClass has no trial any more ([Grants and no trial](#grants-enterprise-and-no-trial)) | | `GET /account/trial` | `/account`, through `/signup` when signed out (genclass.dev's pricing page linked here until 2026-10-19) | -| `GET /account/billing?project=` | one app's plan page (plans per project, `billing/plans.json` version 4): "Choose a plan for ", Team large with its price, what it has and Start Team for (a Checkout for that project: `project_id`, metadata `genclass_project`), and a very small "Try for free (limited)" under it, none for an app past the 3 Free covers; the app's live fixes this month. `&from=cli` (the genclass command's plan step, which waits for the choice), `&new=1` (an app just added on the website, which goes on to its setup), `&start=1` (opens the Checkout at once). `?plan=team` without a project opens the plan page of the account's first app on Free. | +| `GET /account/billing?project=` | one app's plan page (plans per project, `billing/plans.json` version 4): "Choose a plan for ", Team large with its price, what it has and Start Team for (a Checkout for that project: `project_id`, metadata `genclass_project`), and a very small "Skip" under it (Free, after the reason popup; it read "Try for free (limited)" until 2026-11-13), none for an app past the one Free covers; the app's live fixes this month. `&from=cli` (the genclass command's plan step, which waits for the choice), `&new=1` (an app just added on the website, which goes on to its setup), `&start=1` (opens the Checkout at once). `?plan=team` without a project opens the plan page of the account's first app on Free. | | `POST /api/billing/projects//free` | `{ reason, note? }`, browser session and our `Origin`: Free chosen for one app on its plan page, after the popup "What's stopping you from paying?" (`reason` one of `FREE_REASONS` in `src/billing.ts`: price, trying, approval, feature, unsure, other; `note` at most 500 characters). Keeps the answer (`plan_feedback`, migration 0017), writes `projects.plan_chosen` (which `GET /api/cli/overview` reports as `plan_chosen`) and tells the owner (`NOTIFY_WEBHOOK`, kind `free_chosen`). 400 without a reason, 409 for an app past the ones Free covers, 404 for another account's app. Until an own app on Free has a choice (where Team is sold), `/account/apps/` redirects to its plan page. | | `GET /account/billing` | the plan, its renewal or end date, the plans side by side (Free, Team and Enterprise), the seats box and Upgrade (and Switch, with two paid plans) buttons, the Team section (members, invite links, the teams the account is in) and Manage billing (only to an account that can buy), Talk to us (a mailto link) for Enterprise, and which plan is sold through Link (Managed Payments; none today). With no plan sold it shows no Upgrade button, no "billed monthly" and no Stripe footer. `?plan=` (a pricing page button) survives the sign-in redirect and highlights and focuses that plan's button. Back from Checkout (`?done=1`) while the plan still reads Free, or back from a switch (`?done=1&to=`) while the plan is still the old one, it shows no buttons and waits for the webhook. | | `GET /api/account/projects//integrations` | browser session: the app's forwarding destinations, masked | diff --git a/telemetry-worker/src/account-pages.ts b/telemetry-worker/src/account-pages.ts index f4272fd7..c4669d8b 100644 --- a/telemetry-worker/src/account-pages.ts +++ b/telemetry-worker/src/account-pages.ts @@ -656,6 +656,11 @@ export interface AccountView { plan?: string; /** This server runs the GitHub App (src/github/config.ts): the projects view links to /account/github. */ githubApp?: boolean; + /** + * A new account on Free that can buy (no app yet): its first screen is the paid plan's trial, with a tiny Skip on to + * its first app (the owner's call, 2026-11-13: "our entire goal is for them to put their credit cards in"). + */ + trial?: { plan: string; price: string; days: number | null; summary: string; features: string[]; start: string } | null; } const TRANSPORT_WORDS: Record = { @@ -690,6 +695,14 @@ export const ORG_CSS = ` .empty-p{grid-column:1/-1;display:flex;flex-direction:column;align-items:center;gap:12px;padding:56px 16px;border:1px dashed var(--rule2);border-radius:6px;text-align:center} .empty-p h2{font-size:16px} .empty-p p{font-size:13px;color:var(--ink3);max-width:52ch} +.empty-p.trial{border-style:solid;border-color:var(--accent);gap:10px;padding:44px 20px 28px} +.empty-p.trial h2{font-size:28px;letter-spacing:-.01em} +.empty-p.trial .tp{font-size:17px;color:var(--ink);font-weight:600} +.empty-p.trial ul{list-style:none;padding:0;margin:4px 0 6px;display:grid;gap:6px;text-align:left;font-size:14px} +.empty-p.trial li::before{content:"\\2713";color:var(--accent);margin-right:8px} +.empty-p.trial .btn{padding:14px 26px;font-size:16px} +.empty-p.trial .skipit{font-size:11px;margin-top:6px} +.empty-p.trial .skipit a{color:var(--ink3)} .acct-form{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;margin-top:16px;max-width:720px} .acct-form .f{flex:1 1 260px;min-width:0} .acct-form label{display:block;font-size:13px;color:var(--ink2);margin-bottom:6px} @@ -785,7 +798,14 @@ export function accountPage(nonce: string, v: AccountView): string { ? ` ` - : `

    Create your first project

    Fastest: run npx genclass in your app's folder. It creates the project here and sets GenClass up.

    ${icon("plus", 14)}New project
    `; + : v.trial + ? `

    ${v.trial.days ? `Start your ${v.trial.days}-day free trial` : `Start ${esc(v.trial.plan)}`}

    +

    ${esc(v.trial.plan)}, ${esc(v.trial.price)}

    ${esc(v.trial.summary)}

    +
      ${v.trial.features.map((f) => `
    • ${esc(f)}
    • `).join("")}
    + ${v.trial.days ? `Start your ${v.trial.days}-day free trial` : `Start ${esc(v.trial.plan)}`} + ${v.trial.days ? `

    ${v.trial.days} days free, then ${esc(v.trial.price)}. Your card is asked for on Stripe; cancel before the trial ends and you pay nothing.

    ` : ""} +

    Skip

    ` + : `

    Create your first project

    Fastest: run npx genclass in your app's folder. It creates the project here and sets GenClass up.

    ${icon("plus", 14)}New project
    `; // with the GitHub App set up on this server, its page sits next to New project (src/github/pages.ts) const newBtn = `${icon("plus", 14)}New project`; const newProject = v.githubApp ? `` : newBtn; diff --git a/telemetry-worker/src/auth.ts b/telemetry-worker/src/auth.ts index 6252ca84..014d28d6 100644 --- a/telemetry-worker/src/auth.ts +++ b/telemetry-worker/src/auth.ts @@ -82,7 +82,8 @@ import { forgetAccount as forgetAccountGithub, unlinkProject } from "./github/st import { json, mediaType, readCapped } from "./http.js"; import { dashboardPage, ownEndpoint, securityHeaders, setupCommand } from "./pages.js"; import { DEVICE_FAILURES, DEVICE_MS, DUMMY_HASH, FAILURE_WINDOW_MS, FREE_FAILURES, MAX_DELAY_MS, PASSWORD_MAX, delayAfter, hashPassword, needsRehash, passwordProblem, verifyPassword, waitText } from "./password.js"; -import { accountAccess, accountBilling, canBuy, firstPlanWith, FREE, hasAnalytics, hasDashboard, hasProblems, keepDays, PAID_PLANS, pickRange, PLANS_FILE, problemsLock, projectAccess, viewRanges } from "./plans.js"; +import { accountAccess, accountBilling, canBuy, firstPlanWith, FREE, hasAnalytics, hasDashboard, hasProblems, keepDays, PAID_PLANS, pickRange, PLANS_FILE, priceText, problemsLock, projectAccess, viewRanges } from "./plans.js"; +import { trialFor } from "./billing.js"; import { activeOwners, forgetTeam, membershipsOf } from "./team.js"; import { cancelForDeletion } from "./stripe.js"; import { @@ -1956,6 +1957,19 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E if (!a) return redirect("/login?next=%2Faccount"); const view = await accountView(c, a); view.notice = accountNotice(url); + // a new account's first screen is the paid plan's trial, with a tiny Skip on to its first app (the owner's call, + // 2026-11-13: "our entire goal is for them to put their credit cards in") + const sold = PAID_PLANS[0]; + if (!view.projects.length && sold && canBuy(env, a.account) && (await accountAccess(c.db, a.account.id, env, c.now)).source === null) { + view.trial = { + plan: sold.name, + price: priceText(sold), + days: (await trialFor(c.db, a.account.id, sold)) || null, + summary: sold.summary, + features: sold.features.filter((f) => f.available && !/^Everything in /i.test(f.text) && !/days free/i.test(f.text)).map((f) => f.text).slice(0, 8), + start: `/account/billing?plan=${encodeURIComponent(sold.id)}&start=1`, + }; + } return page(200, (n) => accountPage(n, view)); } case "/account/new": { diff --git a/telemetry-worker/src/billing-pages.ts b/telemetry-worker/src/billing-pages.ts index 32dfed47..5005fd6b 100644 --- a/telemetry-worker/src/billing-pages.ts +++ b/telemetry-worker/src/billing-pages.ts @@ -248,7 +248,7 @@ export interface ProjectPlanView { } /** - * One project's plan: what it is on, what it has used, and the paid plan large with "Try free for now (limited)" + * One project's plan: what it is on, what it has used, and the paid plan large with a tiny "Skip" (Free, after the reason) * small under it (the owner's layout, 2026-11-12). The genclass command opens this page when it adds a project. */ export function projectPlanPage(nonce: string, v: ProjectPlanView): string { @@ -301,7 +301,7 @@ export function projectPlanPage(nonce: string, v: ProjectPlanView): string { const card = offer && a.source !== "open" ? `

    ${esc(offer.name)}${covered ? ' Your plan' : ""}

    ${esc(priceText(offer))}

    ${esc(offer.summary)}

      ${features}
    ${action}
    - ${a.locked || a.source !== null ? "" : `

    Try for free (limited)

    + ${a.locked || a.source !== null ? "" : `

    Skip

    What's stopping you from paying?

    diff --git a/telemetry-worker/test/project-plans.test.ts b/telemetry-worker/test/project-plans.test.ts index 5a709dc2..b6d14551 100644 --- a/telemetry-worker/test/project-plans.test.ts +++ b/telemetry-worker/test/project-plans.test.ts @@ -503,7 +503,7 @@ describe("an app past what the plan covers waits on its plan page (Free: one app expect(one).not.toContain('type="button" data-continue>'); expect(one).not.toContain('Your plan'); // Free is the small way on: a very small link under the big card, and a popup that asks why before it goes on - expect(one).toContain('

    Try for free (limited)

    '); + expect(one).toContain('

    Skip

    '); expect(one).toContain(".linkish{background:none;border:0;padding:0;font:inherit;font-size:11px"); expect(one).toContain('

    What\'s stopping you from paying?

    '); for (const [val, words] of FREE_REASONS) expect(one).toContain(``); @@ -682,6 +682,41 @@ describe("POST /api/billing/projects//continue: an app the account's plan co }); }); +describe("the website: a new account's first screen is Pro's trial, with a tiny Skip (the owner's call, 2026-11-13)", () => { + it("an account with no app, on Free, where Pro is sold, sees the trial with its checkout and a Skip on to its first app", async () => { + const t = await withApps([]); + const home = await call(t.env, "GET", "/account", { cookie: t.cookie }); + expect(home.status).toBe(200); + const page = await home.text(); + expect(page).toContain('

    Start your 3-day free trial

    '); + expect(page).toContain('

    Pro, $15 / developer / month

    '); + expect(page).toContain('Start your 3-day free trial'); + expect(page).toContain("3 days free, then $15 / developer / month. Your card is asked for on Stripe; cancel before the trial ends and you pay nothing."); + expect(page).toContain('

    Skip

    '); + expect(page).toContain("
  • Up to 3 apps
  • "); + expect(page).not.toContain("
  • Everything in Free
  • "); + expect(page).not.toContain("Create your first project"); + // the trial's own button opens the account's checkout at once (the billing page, go) + const billing = await (await call(t.env, "GET", "/account/billing?plan=team&start=1", { cookie: t.cookie })).text(); + expect(billing).toContain('"go":true'); + }); + + it("an account with an app, on Pro, or where nothing is sold sees its projects as before", async () => { + const withApp = await withApps(["Shop"]); + expect(await (await call(withApp.env, "GET", "/account", { cookie: withApp.cookie })).text()).not.toContain('id="trial"'); + const pro = await withApps([]); + paidPro(pro.sql, pro.account.id); + const p = await (await call(pro.env, "GET", "/account", { cookie: pro.cookie })).text(); + expect(p).not.toContain('id="trial"'); + expect(p).toContain("Create your first project"); + const none = setup(); + const n = await signedIn(none.env); + const free = await (await call(none.env, "GET", "/account", { cookie: n.cookie })).text(); + expect(free).not.toContain('id="trial"'); + expect(free).toContain("Create your first project"); + }); +}); + describe("the website: an own app opens its plan page until a plan is chosen there, and while the plan does not cover it", () => { it("an app on Free that has not chosen opens its plan page instead of its dashboard, until it has", async () => { const t = await withApps(["Shop"]);