diff --git a/AGENTS.md b/AGENTS.md
index d8e7060c..a3e15317 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -30,7 +30,7 @@ Default mode is `observe` (report only); `guard` is opt-in; `heal` is experiment
| `bench/stall/` | `request-stalled` on the demos (`run.ts`, local, headless Chromium): false reports on clean and chaos trials, recall on planted 60 s hangs; numbers in RESULTS.md section 23 |
| `bench/late-load/` | the lite install's cost (`run.mjs`, train VM, headless Chromium; README "Lite install"): the Angular starter built without GenClass, with the first-line import and with the late loader (a dynamic import, or the script tag), each visited at 4× CPU throttling: load metrics, long tasks, script time, download and what the runtime saw; numbers in RESULTS.md section 28 |
| `compat/`, `templates/` | framework compatibility matrix (`RESULTS.md`) and starter templates (react-vite, nextjs, vue-vite, sveltekit, astro, nuxt, react-router, angular; `compat/templates-check.mjs` builds and boots them in Chromium against the packed runtime, module and integration); evaluation only |
-| `telemetry-worker/` | the diagnostics collector with the dashboards, accounts, plans (`billing/plans.json`: Free, Team per app, Enterprise; `src/plans.ts`) and teams (`src/team.ts`) (Cloudflare Worker), and the GitHub App, genclass-dev[bot], in `src/github/` (webhook, reviews of pull requests from the race files their test runs upload, `@genclass` commands, the link between a repository and an app; off until its secrets are set; `github-app.manifest.json` is the app's permissions and events; tests `test/github*.test.ts` against a fake GitHub; `docs/github-app/`; rule 10). genclass.dev itself (the site, its admin and the pitch deck) and the page check (`scan-worker/`) live in the private repo genclass-dev/genclass-site, which has this repo as its `lib/` submodule |
+| `telemetry-worker/` | the diagnostics collector with the dashboards, accounts, plans (`billing/plans.json`: Free, Team per project, Enterprise; `src/plans.ts`) and teams (`src/team.ts`) (Cloudflare Worker), and the GitHub App, genclass-dev[bot], in `src/github/` (webhook, reviews of pull requests from the race files their test runs upload, `@genclass` commands, the link between a repository and an app; off until its secrets are set; `github-app.manifest.json` is the app's permissions and events; tests `test/github*.test.ts` against a fake GitHub; `docs/github-app/`; rule 10). genclass.dev itself (the site, its admin and the pitch deck) and the page check (`scan-worker/`) live in the private repo genclass-dev/genclass-site, which has this repo as its `lib/` submodule |
| `research/` | earlier Jev research: extension, `jev_local` (Python encoder the training scripts import), benchmarks |
| `docs/adoption/` | listing drafts for directories and registries (`listings/mcp.md`): steps, fields and entries, nothing submitted |
| `docs/github-app/` | the GitHub App: `README.md` (users: setup, what the lines mean, commands, settings, what it reads and keeps), `SETUP.md` (the owner: registering it with `scripts/github-app-setup.mjs`, secrets, the migration, first run), `DESIGN.md` (why it is built this way, threats, what is not built), `workflows/` (two files users copy); the Marketplace listing draft is `docs/adoption/listings/github-marketplace.md` |
diff --git a/PRIVACY.md b/PRIVACY.md
index a5b4ae7a..ab573f88 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -142,11 +142,11 @@ and invite links.
## Paid plans, payments and Stripe
-Free costs nothing, Team is sold on genclass.dev per app (since 12 November 2026; per developer from 11 November), and Enterprise is agreed by
+Free costs nothing, Team is sold on genclass.dev per project (since 12 November 2026; per developer from 11 November), and Enterprise is agreed by
email. A plan decides what your dashboard shows, never what is collected: an app that turned visitor analytics on has
them counted on Free too, and they show once its plan includes them. A paid plan (Team; the Individual plan was one
until 22 October 2026) is paid through Stripe, which processes payments
-for us. We keep the number of apps your subscription pays for, next to its status. When you upgrade, we send Stripe
+for us. We keep which app a subscription pays for (or, for a plan bought per developer, the number of seats), next to its status. When you choose Free for an app, we keep when, the reason you picked and anything you wrote (at most 500 characters), and we read them to improve our plans. When you upgrade, we send Stripe
your account's email (if it has one) and your GenClass account id, and you type your card or other payment details
into Stripe's own checkout page: they go to Stripe and never reach GenClass. Stripe handles them under its own privacy
policy (stripe.com/privacy) and keeps your invoices and receipts, which you can see under Manage billing on your
diff --git a/billing/plans.json b/billing/plans.json
index 87e6d720..f5602f04 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. 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 one person (3 apps, the last 7 days shown) and genclass-dev[bot] on public repositories. Team is sold per app since 2026-11-12 (per_app true: the Stripe subscription's quantity is the number of apps the plan covers, and it holds as many members as the owner invites; telemetry-worker/src/plans.ts appsOf; per_seat true would sell it per person instead, the quantity then being the seats, telemetry-worker/src/team.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 seat for a per_seat plan, per app for a per_app plan); a null yearly price means no yearly option. limits.apps null means no plan limit (for a per_app plan the apps paid for are the 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. There is no trial. 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.",
- "version": 3,
+ "_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. There is no trial. 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.",
+ "version": 4,
"currency": "usd",
"contact": "mehar@genclass.dev",
"grant_plan": "enterprise",
@@ -10,20 +10,22 @@
{
"id": "free",
"name": "Free",
- "summary": "Find, see and fix the problems. For one person.",
+ "summary": "Find, see and fix the problems. Up to 3 projects.",
"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 },
+ "limits": { "apps": 3, "history_days": 7, "seats": 1, "live_fixes_per_month": 100 },
"suggestions": { "per_day": 3, "shown": 3 },
"entitlements": { "dashboard": true, "forwarding": false, "export": false, "analytics": false, "custom_model": false, "github_private": false },
"features": [
{ "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: the problems found, 3 apps, last 7 days", "available": true },
+ { "text": "Dashboard: the problems found, 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": "GitHub App on public repositories", "available": true },
{ "text": "Page check", "available": true },
{ "text": "Suggestions: your top 3 next steps", "available": true },
@@ -33,26 +35,27 @@
{
"id": "team",
"name": "Team",
- "summary": "Everything a team needs, per app.",
+ "summary": "Everything for one project, 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",
"managed_payments": false,
"per_seat": false,
- "per_app": true,
- "limits": { "apps": null, "history_days": 90, "seats": null },
+ "limits": { "apps": null, "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 },
"features": [
{ "text": "Everything in Free", "available": true },
+ { "text": "Unlimited live fixes", "available": true },
{ "text": "Every suggestion, with a prompt for your agent", "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": "Data export and API", "available": true },
{ "text": "90 days of history", "available": true },
- { "text": "Unlimited team members", "available": true }
- ]
+ { "text": "Your whole team on the project", "available": true }
+ ],
+ "per_project": true
},
{
"id": "enterprise",
@@ -63,7 +66,7 @@
"stripe_tax_code": null,
"managed_payments": false,
"per_seat": false,
- "limits": { "apps": null, "history_days": 365, "seats": null },
+ "limits": { "apps": null, "history_days": 365, "seats": null, "live_fixes_per_month": null },
"suggestions": { "per_day": 100, "shown": null },
"entitlements": { "dashboard": true, "forwarding": true, "export": true, "analytics": true, "custom_model": true, "github_private": true },
"features": [
diff --git a/package-lock.json b/package-lock.json
index 6f7bf614..18d97eb0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16819,7 +16819,7 @@
},
"packages/cli": {
"name": "genclass",
- "version": "0.3.4",
+ "version": "0.3.5",
"license": "Apache-2.0",
"bin": {
"genclass": "bin/genclass.mjs"
diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md
index f90415fa..2ac42424 100644
--- a/packages/cli/CHANGELOG.md
+++ b/packages/cli/CHANGELOG.md
@@ -1,5 +1,29 @@
# Changelog
+## 0.3.5
+
+For onboarding paying customers:
+
+- **Every app chooses its plan on a page.** Once the app is in the account, the setup opens the app's plan page in
+ the browser: Team large, with its price and a Start button, and a very small "Try for free (limited)" under it.
+ Free asks what is stopping them from paying first. The terminal shows the same card and waits for the choice made
+ there; the setup goes on only once the app has its plan. Plans are per
+ app now: Team is $15 a project a month with the whole team on it, Free covers 3 apps and 100 problems fixed live a
+ month per app. A fourth app has only Team. Before, the plan was a card in the terminal only, and a setup run
+ without a keyboard (`--yes`, a coding agent) chose Free without showing anything.
+- **`genclass` alone works.** A run through `npx genclass` on a terminal installs the command (`npm install -g
+ genclass`) and says so; `npm uninstall -g genclass` takes it out, `GENCLASS_NO_INSTALL=1` turns it off.
+- **Numbered choices.** The home lists its commands with numbers (type 3 for analytics), and "Which app?" is a
+ numbered list instead of commands to retype.
+- **`genclass profile`**: who is signed in, each app's plan, and numbered choices to upgrade, open billing or sign
+ out. **`genclass upgrade`** opens the plan page of this folder's app (`--app` for another).
+- On Free, every command ends with one line: the limits and the way up (`genclass upgrade`).
+- **`genclass suggest` hands the whole list to your coding agent**: after the list, one number starts Claude Code,
+ Codex, Cursor or Gemini CLI in the folder with every item, or copies or prints the prompt.
+- `genclass dashboard` shows the live fixes used this month, and says when an app on Free has used them all and
+ GenClass only observes until the month ends.
+- `genclass apps new` says when the new app needs a plan of its own, with its plan page.
+
## 0.3.4
From a review of every setup path after a teammate hit the 0.3.2 bug:
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 09bf0c1f..cc90b754 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.0 ─╮
+ ╭─ GenClass ────────────────────────────────────────────── v0.3.5 ─╮
│ │
│ Welcome back, Ada! │
│ │
@@ -50,34 +50,36 @@ it, and `d` again to fold it back.
When the walk is done (or you press `q` to leave it for later), the command does not end. It asks what next:
```text
- What next? A command, or a question about your app.
- dashboard what GenClass found on your live site
- problems every problem, by kind and page
- analytics visitors, pages, sources, clicks
- events the latest events, one per line
- fix hand a problem to your coding agent
- ask ask about your app (or just type it)
- connections alerts, GitHub, your coding agent
- suggest what your app is missing
- help every command q leave
+ What next? A number, a command, or a question.
+ 1 dashboard what GenClass found on your live site
+ 2 problems every problem, by kind and page
+ 3 analytics visitors, pages, sources, clicks
+ 4 events the latest events, one per line
+ 5 fix hand a problem to your coding agent
+ 6 ask ask about your app (or just type it)
+ 7 connections alerts, GitHub, your coding agent
+ 8 suggest what your app is missing
+ 9 profile your account and plan, and upgrade
+ help every command q leave
❯ which page loses the most visitors?
```
-Type a command, or a sentence: a sentence is a question about your app. `q` leaves. Everything here is also a
+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.
-**Just `genclass`.** `npx` runs the command without installing it, so `genclass` alone is not a command yet. The
-first time the home opens it offers to install it (`npm install -g genclass`, shown before it runs, asked once).
-After that you type `genclass`, and every screen says `genclass` instead of `npx genclass`.
+**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
+it off). After that you type `genclass`, and every screen says `genclass` instead of `npx genclass`.
## Every part, in order
| 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. |
-| Plan | On Free, shows the paid plan first: Enter opens its checkout in your browser, `f` goes on with Free for now. Asked once per project. | 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. |
| 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. |
@@ -189,7 +191,8 @@ App is doing on the repository, whether your coding agent is connected, and whet
## Account
`npx genclass apps` lists your apps (`apps new "Name"` makes one). `npx genclass plan` shows your plan, its limits
-and what is used; on Free it shows the paid plan and Enter opens its checkout. `npx genclass team` shows who is on
+and what is used, and each app's plan. `npx genclass profile` shows your account and each app's plan with numbered
+choices (upgrade, billing, sign out); `npx genclass upgrade` opens the plan page of this folder's app. `npx genclass team` shows who is on
the plan (`team invite` makes a single-use invite link).
**What a terminal may do** is listed on the page where you approve its sign-in, and nothing else: read your apps'
@@ -229,8 +232,8 @@ prompt) is shown for the first three items on Free and for all of them on Team,
## Commands
-`npx` runs the command without installing it. To type just `genclass`, install it once: `npm install -g genclass`
-(the command offers to, the first time its home opens).
+`npx` runs the command without installing it. The first run on a terminal installs it, so you can type just
+`genclass` (by hand: `npm install -g genclass`).
| | |
|---|---|
@@ -246,12 +249,13 @@ prompt) is shown for the first three items on Free and for all of them on Team,
| `npx genclass export` | every stored event as a file (`--from`, `--to`, `--format csv`, `--out`) |
| `npx genclass fix [number, kind or all]` | hand a problem to your coding agent (`--print`, `--copy`, `--agent `) |
| `npx genclass ask "..."` | a question about your app, answered from its numbers |
-| `npx genclass suggest [url]` | what your app is missing (`--yes`, `--no-model`, `--all`) |
+| `npx genclass suggest [url]` | what your app is missing, then one number hands the list to your coding agent (`--yes`, `--no-model`, `--all`) |
| `npx genclass scan ` | 23 visual checks on one page |
| `npx genclass connections` | alerts, GitHub App, coding agent (`add`, `test`, `on`, `off`, `remove` a destination) |
| `npx genclass agent` | your coding agent and what it did (`agent link`) |
| `npx genclass funnels` | funnels (`add "Name" /a /b`, `remove "Name"`) |
| `npx genclass apps`, `plan`, `team` | your apps, your plan, the people on it |
+| `npx genclass profile`, `upgrade` | your account and each app's plan; Team for this app |
| `npx genclass login`, `logout`, `whoami` | your account |
| `npx genclass remove` | take GenClass out of the app again |
diff --git a/packages/cli/package.json b/packages/cli/package.json
index bb2c3d50..401f7047 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "genclass",
- "version": "0.3.4",
+ "version": "0.3.5",
"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 6ea90ea8..0700c206 100644
--- a/packages/cli/src/actions.mjs
+++ b/packages/cli/src/actions.mjs
@@ -28,7 +28,7 @@ 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, good, note, planCard, PROMPT, spinner, typed, warn } from "./tui.mjs";
+import { agree, bad, clean, good, note, planCard, PROMPT, spinner, typed, warn } from "./tui.mjs";
import { planViteSettings } from "./vite.mjs";
/** Resolves after `ms`, or at once when `signal` aborts. */
@@ -113,6 +113,8 @@ export async function signIn(o = {}) {
export async function choosePlan(state, o = {}, deps = {}) {
const offer = state.steps.find((s) => s.id === "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) => {
@@ -140,27 +142,67 @@ export async function choosePlan(state, o = {}, deps = {}) {
* @param {{ fetchFn?: typeof fetch, waitMs?: number, everyMs?: number }} [deps]
*/
export async function checkout(offer, o = {}, deps = {}) {
+ return (await waitForPlan(offer, o, deps)) === "paid";
+}
+
+/**
+ * One app's plan, on its page in the browser (genclass.dev/account/billing?project=): the paid plan large, with
+ * its price and what it has, and a very small "Try for free (limited)" under it (none for an app past the ones Free
+ * covers). The command opens the page and waits for the choice made there: a payment, or Free for now. Without a
+ * terminal (--yes, a coding agent) it opens the page and goes on, and the step stays open until the choice is made.
+ * Returns "done" once the app has its plan; "quit" when the wait ended without a choice (the setup stops there);
+ * "skipped" without a terminal.
+ * @param {{ plan: { name: string }, price: string, free: { name: string, apps: number | null }, checkoutUrl: string, project: { id: string, name: string, token: string }, needs: boolean }} offer
+ */
+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 (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.");
+ return "skipped";
+ }
+ const got = await waitForPlan(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}`);
+ return "quit";
+}
+
+/**
+ * Opens a plan's page in the browser and waits (Enter stops the wait): for the account's plan (a server from before
+ * plans per project), or for one app's: its payment, or Free chosen for it on the page. The page is genclass.dev's,
+ * the payment is Stripe's; this command never sees a card. Returns "paid", "free" or null.
+ */
+async function waitForPlan(offer, o = {}, deps = {}) {
+ const app = offer.project ? clean(offer.project.name, 30) : null;
out();
out(` Open ${c.cyan(offer.checkoutUrl)}`);
- if (openBrowser(offer.checkoutUrl, o)) note("Your browser is opening the checkout.");
+ 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(`Waiting for the payment in the browser${canStop()}`);
+ const sp = spinner(`${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 paid = false;
+ let got = null;
while (creds && !stop.signal.aborted && Date.now() < until) {
await nap(deps.everyMs ?? 3000, stop.signal);
if (stop.signal.aborted) break;
const me = await fetchOverview(creds, deps);
- if (me.ok && me.plan && me.plan.id !== "free") {
- paid = true;
- break;
- }
+ 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";
+ } else if (me.plan && me.plan.id !== "free") got = "paid";
+ if (got) break;
}
await stop.done();
- if (paid) sp.stop("ok", `${offer.plan.name} is active. Thank you.`);
- else sp.stop("warn", `No payment yet. ${offer.plan.name} starts the moment the payment goes through.`);
- return paid;
+ 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;
}
// ------------------------------------------------------------------------------------------------ 2. the runtime
diff --git a/packages/cli/src/api.mjs b/packages/cli/src/api.mjs
index f54ccd37..1c9c4bbd 100644
--- a/packages/cli/src/api.mjs
+++ b/packages/cli/src/api.mjs
@@ -32,7 +32,7 @@ export async function fetchOverview(creds, opts = {}) {
if (!r.ok && r.status === 404) r = await request("/api/me", base);
if (!r.ok) return { ok: false, status: r.status, reason: failure(r) };
const j = r.json ?? {};
- const projects = (Array.isArray(j.projects) ? j.projects : []).map((p) => cleanProject(p, creds.host)).filter(Boolean);
+ const projects = (Array.isArray(j.projects) ? j.projects : []).map((p) => withPlan(cleanProject(p, creds.host), p, creds.host)).filter(Boolean);
const account = j.account && typeof j.account === "object" ? { id: String(j.account.id ?? ""), email: str(j.account.email), github_login: str(j.account.github_login, 60) } : creds.account;
if (!full) return { ok: true, account, plan: null, suggestions: null, projects, github: null, billingUrl: null };
const s = j.suggestions && typeof j.suggestions === "object" ? j.suggestions : null;
@@ -49,6 +49,16 @@ export async function fetchOverview(creds, opts = {}) {
};
}
+/**
+ * An app with its plan, when the server says it (plans are per project since genclass.dev's billing/plans.json
+ * version 4): `plan` { id, name, source }, `needsPlan` for an app past the ones Free covers, `planChosen` once Free
+ * was chosen for it on its plan page, `planUrl` that page (on the server's own host, or none).
+ */
+function withPlan(app, p, host) {
+ if (!app || !p?.plan || typeof p.plan !== "object" || !str(p.plan.id, 40)) return app;
+ return { ...app, plan: { id: str(p.plan.id, 40), name: str(p.plan.name, 40) ?? str(p.plan.id, 40), source: str(p.plan.source, 20) }, needsPlan: p.needs_plan === true, planChosen: p.plan_chosen === true, planUrl: sameOrigin(p.plan_url, host) };
+}
+
/**
* The plan an account on Free can start, in the server's own words (its name, price and the lines of its card come
* from genclass.dev's plans, never from this command). Null when the server offers none, or when what it sent is
@@ -68,7 +78,7 @@ function offerOf(o, host) {
summary: str(o.summary, 200),
features: (Array.isArray(o.features) ? o.features : []).filter((x) => typeof x === "string" && x.trim()).slice(0, 12).map((x) => x.slice(0, 120)),
checkoutUrl,
- free: { name: str(f.name, 40) ?? "Free", apps: num(f.apps), historyDays: num(f.history_days), suggestionsShown: num(f.suggestions_shown) },
+ 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) },
};
}
@@ -147,6 +157,13 @@ export async function signInIsOld(creds, opts = {}) {
return old(await ask("/api/cli/app?id=-&range=24h"));
}
+/** The live-fix count the server sent with an app's dashboard (src/usage.ts there), or null. */
+function liveUsage(u) {
+ if (!u || typeof u !== "object" || !Number.isFinite(Number(u.used))) return null;
+ const lim = u.limit === null || u.limit === undefined || !Number.isFinite(Number(u.limit)) ? null : Math.max(0, Math.round(Number(u.limit)));
+ return { used: Math.max(0, Math.round(Number(u.used))), limit: lim, observeOnly: u.observe_only === true, resets: str(u.resets, 40) };
+}
+
/**
* One app's dashboard as data, for `genclass dashboard`: the counts the browser dashboard shows, for the period.
* `reauth` is true when this sign-in was approved before the command could show dashboards (sign in again);
@@ -186,6 +203,10 @@ export async function fetchApp(creds, id, range = "7d", opts = {}) {
visitorsPlan: lock ? { plan: str(lock.plan, 40), upgradeUrl: sameOrigin(lock.upgrade_url, creds.host) } : null,
agent: { found: count(st.found), reproduced: count(st.reproduced), fixed: count(st.fixed), closed: count(st.closed), open: count(st.open), last: str(j.agent?.last, 40) },
dashboardUrl: sameOrigin(j.dashboard_url, creds.host),
+ /** the problems fixed live this month against the app's plan: past its limit GenClass observes only */
+ usage: liveUsage(j.usage),
+ /** the app's plan page (plans per project) */
+ planUrl: sameOrigin(j.plan_url, creds.host),
/** the recorded demo's numbers stand in for measured ones (the server's sample app) */
demo: j.demo === true,
// The rest of what the dashboard page reads, for the other views (views.mjs): every total and breakdown by the
diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs
index 56fc127b..e7192322 100644
--- a/packages/cli/src/app.mjs
+++ b/packages/cli/src/app.mjs
@@ -16,7 +16,13 @@ import { c, interactive, out, sym } from "../../runtime/bin/lib/ui.mjs";
import { openBrowser, signIn } from "./actions.mjs";
import { fetchApp, signInIsOld } from "./api.mjs";
import { readState } from "./state.mjs";
-import { ago, bad, clean, header, note, typed, visible, warn, width, wrap } from "./tui.mjs";
+import { ago, bad, clean, header, note, PROMPT, typed, visible, warn, width, wrap } from "./tui.mjs";
+
+/** "Dec 1" for an ISO time (UTC). */
+const monthDay = (iso) => {
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime()) ? String(iso).slice(0, 10) : d.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
+};
/** The model's default kinds in plain words (packages/runtime/src/situation/questions.ts DEFAULT_DIAGNOSES). */
export const KINDS = {
@@ -98,6 +104,14 @@ export function render(app, state, o = {}) {
const cell = (label, value) => `${c.gray(label.padEnd(15))}${c.bold(String(value).padEnd(9))}`;
out(` ${c.gray(RANGE_WORDS[app.range] ?? app.range)}${app.lastEvent ? c.gray(` · last page ${ago(app.lastEvent, o.now)}`) : ""}`);
out();
+ // past this month's live fixes: GenClass observes only, and the way on is the app's plan
+ const u = app.usage;
+ if (u?.observeOnly && u.limit !== null) {
+ const [first = "", ...rest] = wrap(`Observing only. This app used its ${n(u.limit)} live fixes for the month, so GenClass finds and reports problems and fixes none${u.resets ? ` until ${monthDay(u.resets)}` : ""}. A paid plan has no limit: npx genclass plan`, width() - 6);
+ out(` ${c.yellow(sym.warn)} ${c.bold(first)}`);
+ for (const l of rest) out(` ${l}`);
+ out();
+ }
// the dashboard page's own words: a page load is one page GenClass watched
out(` ${cell("Page loads", n(t.visits))} ${cell("Problems found", n(t.found))}`.trimEnd());
out(` ${cell("Handled", n(t.handled))} ${cell("Visual checks", n(t.checks))}`.trimEnd());
@@ -132,6 +146,8 @@ export function render(app, state, o = {}) {
part("Visitors", v.visits ? `${plural(v.visits, "visit")} · ${plural(v.pages, "page view")}${bounce}` : c.gray("none counted in this period (analytics is opt-in: genclass.dev/docs)"));
if (v.visits && v.entry.length) for (const l of wrap(`came in on ${v.entry.slice(0, all ? 10 : 3).map((e) => `${clean(e.key, 30)} (${n(e.count)})`).join(", ")}`, width() - 4 - 15)) out(` ${" ".repeat(15)}${c.gray(l)}`);
} else if (app.visitorsPlan) part("Visitors", c.gray(`visitor analytics come with the ${clean(app.visitorsPlan.plan ?? "Team", 20)} plan${app.visitorsPlan.upgradeUrl ? `: ${app.visitorsPlan.upgradeUrl}` : ""}`));
+ if (u && u.limit !== null) part("Live fixes", u.observeOnly ? c.yellow(`${n(u.used)} of ${n(u.limit)} this month · observing only`) : `${n(u.used)} of ${n(u.limit)} this month`);
+ else if (u?.used) part("Live fixes", `${n(u.used)} this month · no limit`);
const a = app.agent;
const agentStep = state?.steps?.find((s) => s.id === "agent");
part("Coding agent", a.found ? `${n(a.found)} found · ${n(a.reproduced)} reproduced · ${n(a.fixed)} fixed · ${n(a.closed)} with a test` : agentStep?.state === "done" ? c.gray("connected · nothing reported in this period") : c.gray("not connected yet: npx genclass setup"));
@@ -176,6 +192,10 @@ export async function dashboard(o = {}, deps = {}) {
}
/** How an app is named after --app: its name in quotes, or its id when another app has the same name. */
+/** An app as a choice in a list: its name, and its id's start when the name is shared or there is none. */
+export const appLabel = (p, projects) =>
+ p.name === "unnamed" || projects.filter((x) => x.name.toLowerCase() === p.name.toLowerCase()).length > 1 ? `${p.name === "unnamed" ? "unnamed app" : p.name} (${String(p.id).slice(0, 6)})` : p.name;
+
export const appArg = (p, projects) => {
const name = clean(p.name, 200);
// a name that is shared, cut, or holds a quote is not typed: the id always works
@@ -205,6 +225,19 @@ export async function pickApp(o = {}, deps = {}, name = "dashboard") {
// which app: --app by id or by name, else this folder's, else the account's only one
const byId = o.app ? projects.filter((p) => p.id === o.app) : [];
const wanted = byId.length ? byId : o.app ? projects.filter((p) => p.name.toLowerCase() === String(o.app).toLowerCase()) : state.app.token ? projects.filter((p) => p.token === state.app.token) : projects.length === 1 ? projects : [];
+ // 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 });
+ 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();
+ const a = await typed(` ${PROMPT()} ${c.gray("a number ·")} ${c.bold("q")} ${c.gray("back")} `);
+ const i = a !== null && /^\d+$/.test(a.trim()) ? Number(a.trim()) - 1 : -1;
+ if (list[i]) return { state, creds, target: list[i] };
+ out();
+ return { code: 2 };
+ }
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 {
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
index f1fc0df0..63a18ffe 100644
--- a/packages/cli/src/cli.mjs
+++ b/packages/cli/src/cli.mjs
@@ -11,7 +11,7 @@
// - Nothing here reads a file or the network: it parses and dispatches.
import { hostConfig } from "../../runtime/bin/lib/host.mjs";
-import { c, err, out } from "../../runtime/bin/lib/ui.mjs";
+import { c, err, interactive, out } from "../../runtime/bin/lib/ui.mjs";
import { BUILD } from "./assets.mjs";
export const USAGE = `genclass: GenClass in your terminal. Set it up, see what it finds, have it fixed.
@@ -122,6 +122,18 @@ export async function run(argv, ctx = {}) {
return 2;
}
const runSuggest = async (so) => (await import("./suggest/index.mjs")).suggest(so);
+ // on Free, a command run on its own ends with one line saying so, and the way up (the home says it after each)
+ const upsold = async (code, line = true) => {
+ const c0 = await code;
+ if (line && !ctx.nested && !o.json && !o.yes && interactive()) {
+ try {
+ await (await import("./manage.mjs")).freeLine(o);
+ } catch {
+ /* best effort */
+ }
+ }
+ return c0;
+ };
if (cmd === "suggest") {
const url = o.url ?? rest.find((a) => !a.startsWith("-")) ?? null;
const unknown = rest.filter((a) => a.startsWith("-"));
@@ -129,7 +141,7 @@ export async function run(argv, ctx = {}) {
err(`genclass suggest: unknown option ${unknown[0]}\n\n${USAGE}`);
return 2;
}
- return runSuggest({ ...o, url });
+ return upsold(runSuggest({ ...o, url }));
}
if (cmd !== null && !COMMANDS.includes(cmd)) {
const near = nearest(cmd);
@@ -148,16 +160,16 @@ export async function run(argv, ctx = {}) {
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 (await import("./app.mjs")).dashboard(o, { fallback });
+ return upsold((await import("./app.mjs")).dashboard(o, { fallback }));
}
- if (["problems", "events", "health", "report"].includes(cmd)) return (await import("./views.mjs"))[cmd](o);
+ 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 analytics({ ...o, section: words[0] ?? null });
+ return upsold(analytics({ ...o, section: words[0] ?? null }));
}
if (cmd === "agent") {
if (words[0] === "link") return (await import("./manage.mjs")).agentLink(o);
@@ -167,15 +179,17 @@ export async function run(argv, ctx = {}) {
}
return (await import("./views.mjs")).agent(o);
}
- if (cmd === "fix") return (await import("./fix.mjs")).fix({ ...o, what: words.join(" ") || null });
- if (cmd === "ask") return (await import("./ask.mjs")).ask({ ...o, question: words.join(" ") });
- if (["apps", "plan", "team", "connections", "funnels"].includes(cmd)) return (await import("./manage.mjs"))[cmd]({ ...o, args: words });
+ if (cmd === "fix") return upsold((await import("./fix.mjs")).fix({ ...o, what: words.join(" ") || null }));
+ if (cmd === "ask") return upsold((await import("./ask.mjs")).ask({ ...o, question: words.join(" ") }));
+ if (["apps", "plan", "team", "connections", "funnels", "profile", "upgrade"].includes(cmd)) return upsold((await import("./manage.mjs"))[cmd]({ ...o, args: words }), ["apps", "connections", "funnels"].includes(cmd));
if (cmd === "export") return (await import("./manage.mjs")).exportEvents(o);
const { setup, status } = await import("./setup.mjs");
if (cmd === "status") return status(o);
if (cmd === "setup" || cmd === null) {
// on a terminal the command stays open when the walk ends: a prompt for commands and questions (home.mjs)
- const home = async (ho) => (await import("./home.mjs")).home(ho, { runCommand: (a) => run(a, { nested: true }), nearest });
+ const home = async (ho) => (await import("./home.mjs")).home(ho, { runCommand: (a) => run(a, { nested: true }), nearest, afterCommand: async () => (await import("./manage.mjs")).freeLine({ cwd: ho.cwd }) });
+ // `genclass` alone works from the first run on (home.mjs installShortName)
+ if (!ctx.nested && !o.yes && !o.json) await (await import("./home.mjs")).installShortName();
return setup({ ...o, ...(ctx.nested ? { home: false } : {}) }, { runSuggest, home });
}
return 2;
@@ -184,7 +198,7 @@ export async function run(argv, ctx = {}) {
/** 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",
- "apps", "plan", "team", "login", "logout", "whoami", "connect", "init", "remove", "help", "version",
+ "apps", "plan", "upgrade", "profile", "team", "login", "logout", "whoami", "connect", "init", "remove", "help", "version",
];
/** The command a mistyped one is closest to (at most two edits away), or null. */
diff --git a/packages/cli/src/home.mjs b/packages/cli/src/home.mjs
index 623459f2..1747a7e8 100644
--- a/packages/cli/src/home.mjs
+++ b/packages/cli/src/home.mjs
@@ -4,15 +4,18 @@
// Invariants:
// - Everything here is also a command of its own (`genclass dashboard`, `genclass analytics`, ...): the home only
// saves typing the name in front, and keeps the folder that was chosen at the start.
-// - Simple first: the list of commands is short lines, shown once and again on an empty Enter or `help`. A mistyped
-// command names the nearest one. A line that is not a command and reads like a sentence is a question (ask.mjs).
+// - Simple first: the list of commands is short numbered lines, shown once and again on an empty Enter or `help`.
+// A number runs that command. A mistyped command names the nearest one. A line that is not a command and reads
+// like a sentence is a question (ask.mjs).
+// - After every command, an app on Free that can have a paid plan here is told so in one line (the owner's call,
+// 2026-11-12: every query says it), with the way up: upgrade.
// - It never opens without someone at a terminal: piped, in CI or with --yes the command prints and ends as before.
// - A command that fails does not end the home: its message is printed and the prompt comes back.
import { c, interactive, out } from "../../runtime/bin/lib/ui.mjs";
import { BUILD } from "./assets.mjs";
import { installGlobally, isShort, readPrefs, savePrefs, shortNameWorks, useShortName } from "./name.mjs";
-import { agree, inHome, note, PROMPT, rule, spinner, typed, warn } from "./tui.mjs";
+import { inHome, note, PROMPT, rule, spinner, typed, warn } from "./tui.mjs";
/** The commands of the home, in the order they are listed: [what is typed, what it is, shown in the short list]. */
export const COMMANDS = [
@@ -24,6 +27,7 @@ export const COMMANDS = [
["ask", "ask about your app (or just type it)", true],
["connections", "alerts, GitHub, your coding agent", true],
["suggest", "what your app is missing", true],
+ ["profile", "your account and plan, and upgrade", true],
["health", "how GenClass runs on your pages", false],
["report", "the period as Markdown", false],
["export", "every stored event, as a file", false],
@@ -32,6 +36,7 @@ export const COMMANDS = [
["scan ", "check one page", false],
["apps", "the apps in your account", false],
["plan", "your plan and what it includes", false],
+ ["upgrade", "a paid plan for this app", false],
["team", "the people on your plan", false],
["status", "where the setup stands", false],
["setup", "walk the setup again", false],
@@ -61,6 +66,11 @@ export function read(line, known = NAMES) {
if (!argv.length) return { help: true };
const head = argv[0].toLowerCase();
if (["q", "quit", "exit", "bye"].includes(head)) return { quit: true };
+ // a number is the command listed with it
+ if (/^\d{1,2}$/.test(head) && argv.length === 1) {
+ const cmd = COMMANDS[Number(head) - 1];
+ return cmd ? { command: [cmd[0].split(" ")[0]] } : { unknown: head };
+ }
if (head === "help" || head === "?" || head === "commands") return { help: true, all: true };
if (known.includes(head)) return { command: [head, ...argv.slice(1)] };
// a sentence, or anything ending in a question mark, is a question about the app
@@ -71,36 +81,46 @@ export function read(line, known = NAMES) {
/** The list of commands: the short one, or every command. */
export function menu(all = false) {
out();
- out(` ${c.bold("What next?")} ${c.gray("A command, or a question about your app.")}`);
- const list = COMMANDS.filter(([, , short]) => all || short);
+ out(` ${c.bold("What next?")} ${c.gray("A number, a command, or a question.")}`);
+ const list = COMMANDS.map((x, i) => [...x, i + 1]).filter(([, , short]) => all || short);
const pad = Math.max(...list.map(([n]) => n.length)) + 2;
- for (const [name, what] of list) out(` ${c.cyan(name.padEnd(pad))}${c.gray(what)}`);
- out(` ${c.cyan((all ? "q" : "help").padEnd(pad))}${c.gray(all ? "leave" : "every command")}${all ? "" : ` ${c.cyan("q")} ${c.gray("leave")}`}`);
+ for (const [name, what, , i] of list) out(` ${c.gray(String(i).padStart(2))} ${c.cyan(name.padEnd(pad))}${c.gray(what)}`);
+ out(` ${c.cyan((all ? "q" : "help").padEnd(pad))}${c.gray(all ? "leave" : "every command")}${all ? "" : ` ${c.cyan("q")} ${c.gray("leave")}`}`);
}
-/** Offers, once, to install the command so that `genclass` alone works. Nothing is installed without a yes. */
-export async function offerInstall(deps = {}) {
- if (isShort() || process.env.CI || readPrefs().installAsked) return "skipped";
- savePrefs({ installAsked: true });
- out();
- out(` ${c.bold("Type just")} ${c.cyan("genclass")} ${c.bold("from now on?")}`);
- note("Right now this command runs through npx and is not installed. Installing it runs: npm install -g genclass");
- if (!(await agree("Install it?"))) {
- note("No problem: npx genclass keeps working. To install it later: npm install -g genclass");
- return "declined";
+const DAY = 24 * 60 * 60_000;
+
+/**
+ * Makes `genclass` alone work in the terminal: a run through npx on a terminal installs the command (npm install
+ * -g genclass@) and says so (the owner's call, 2026-11-12: it installs itself). Not with --yes, without
+ * a terminal, in CI, or with GENCLASS_NO_INSTALL=1; after a failure it is tried again a day later, and the screen
+ * says how to do it by hand. npm uninstall -g genclass takes it out.
+ */
+export async function installShortName(deps = {}) {
+ if (isShort() || process.env.CI || process.env.GENCLASS_NO_INSTALL || !(deps.interactive ?? interactive)()) return "skipped";
+ if ((deps.works ?? shortNameWorks)()) {
+ useShortName();
+ return "skipped";
}
- const sp = spinner("Installing the genclass command");
+ const p = readPrefs();
+ const now = deps.now ?? Date.now();
+ if (p.installFailedAt && now - Date.parse(p.installFailedAt) < DAY) return "skipped";
+ out();
+ const sp = spinner("Adding the genclass command to your terminal");
const r = await (deps.install ?? installGlobally)(BUILD.version);
if (!r.ok) {
- sp.stop("bad", "Could not install it.");
+ savePrefs({ installFailedAt: new Date(now).toISOString() });
+ sp.stop("warn", "Could not add the genclass command. npx genclass keeps working.");
if (r.text) note(r.text);
note("By hand: npm install -g genclass (some systems need sudo in front).");
return "failed";
}
+ savePrefs({ installFailedAt: null, installedAt: new Date(now).toISOString() });
if ((deps.works ?? shortNameWorks)()) {
useShortName();
- sp.stop("ok", "Installed. From now on, type genclass.");
- } else sp.stop("warn", "Installed, but its folder is not on your PATH in this window. Open a new terminal and type genclass.");
+ sp.stop("ok", "Added: type genclass from now on.");
+ } else sp.stop("ok", "Added. Open a new terminal window and type genclass.");
+ note("npm install -g genclass ran. npm uninstall -g genclass takes it out.");
return "installed";
}
@@ -111,7 +131,7 @@ export async function offerInstall(deps = {}) {
*/
export async function home(o = {}, deps = {}) {
if (!interactive()) return 0;
- await offerInstall(deps);
+ await installShortName(deps);
menu();
inHome(o.app ?? "");
for (;;) {
@@ -138,6 +158,14 @@ export async function home(o = {}, deps = {}) {
} catch (e) {
warn(`That did not work: ${e?.message ?? e}`);
}
+ // on Free: one line, every time, with the way up
+ if (!["upgrade", "profile", "plan", "login", "logout", "remove"].includes(argv[0])) {
+ try {
+ await deps.afterCommand?.();
+ } catch {
+ /* the line is best effort */
+ }
+ }
rule();
}
inHome(null);
diff --git a/packages/cli/src/manage.mjs b/packages/cli/src/manage.mjs
index a77d59b3..26efe65d 100644
--- a/packages/cli/src/manage.mjs
+++ b/packages/cli/src/manage.mjs
@@ -16,8 +16,8 @@ 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 } from "./actions.mjs";
-import { changeConnection, createInvite, fetchAccount, fetchApp, fetchConnections, fetchEvents, makeAgentLink, saveFunnels } from "./api.mjs";
+import { checkout, mcpServer, openBrowser, projectPlan } 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";
@@ -79,6 +79,13 @@ export async function apps(o = {}, deps = {}) {
} else {
good(`Made ${c.bold(clean(r.name, 60))}.`);
row("Project key", r.token);
+ // plans are per project: an app past the ones Free covers needs its own, and its page says so
+ 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.");
+ 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)}`);
note("To put GenClass into that app's code, run npx genclass in its folder and pick this app.");
}
out();
@@ -124,10 +131,14 @@ export async function plan(o = {}, deps = {}) {
if ("code" in s) return s.code;
const { state, creds } = s;
const a = await asking(o, creds, (cr) => fetchAccount(cr, deps), deps);
- // an older sign-in or an older server: what the overview knows is still an answer
- const offer = a.ok ? a.offer : (state.steps.find((x) => x.id === "plan")?.offer ?? null);
+ // 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");
+ const mine = (state.projects ?? []).filter((p) => p.plan);
+ const offer = step?.offer ?? (mine.length ? null : a.ok ? a.offer : null);
if (o.json) {
- 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: a.offer } : { 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 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));
return 0;
}
top(state);
@@ -149,12 +160,26 @@ export async function plan(o = {}, deps = {}) {
if (has.length) row("Includes", has.join(", "));
if (lacks.length) row("Not included", lacks.join(", "));
}
+ // each app with its plan
+ if (mine.length) {
+ 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));
+ out(` ${clean(p.name, 24).padEnd(24)} ${what}${p.token === state.app?.token ? c.gray(" this folder") : ""}`);
+ }
+ }
if (!offer) {
out();
out(` ${c.gray("Billing, in the browser")} ${c.cyan(billingUrl)}`);
out();
return 0;
}
+ if (offer.project) {
+ await projectPlan(offer, o, deps);
+ out();
+ return 0;
+ }
planCard(offer, null);
if (o.yes || !interactive()) {
out(` ${c.gray(`Start ${clean(offer.plan.name, 20)}:`)} ${c.cyan(offer.checkoutUrl)}`);
@@ -172,6 +197,139 @@ export async function plan(o = {}, deps = {}) {
return 0;
}
+// ------------------------------------------------------------------------------------- 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(", ");
+
+/**
+ * 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
+ * after every command of the home and at the end of a command run on its own (the owner's call, 2026-11-12). Nothing
+ * for an app with a plan, signed out, or where nothing is sold. Returns whether it printed.
+ */
+export async function freeLine(o = {}, deps = {}) {
+ let state;
+ try {
+ state = await readState({ cwd: o.cwd }, deps);
+ } catch {
+ 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 none, 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}`);
+ return true;
+}
+
+/**
+ * `genclass upgrade`: a paid plan for this folder's app (--app for another), on its plan page in the browser: the
+ * paid plan large and "Try for free (limited)" small. The terminal shows the plan's card and waits for the choice.
+ */
+export async function upgrade(o = {}, deps = {}) {
+ const s = await signedIn(o, deps, "upgrade");
+ if ("code" in s) return s.code;
+ const { state } = s;
+ const offer = upgradeOffer(state, o.app ?? null);
+ const billingUrl = state.account?.billingUrl ?? hostUrl("/account/billing");
+ if (o.json) {
+ out(JSON.stringify({ ok: true, app: offer?.project?.name ?? null, plan: offer?.plan ?? null, price: offer?.price ?? null, url: offer?.checkoutUrl ?? billingUrl }));
+ return 0;
+ }
+ top(state);
+ if (!offer) {
+ const here = (state.projects ?? []).find((p) => p.token === state.app?.token && p.plan);
+ if (here && here.plan.id !== "free") good(`${clean(here.name, 30)} is on ${clean(here.plan.name, 20)}.`);
+ else note("Nothing to upgrade here: every app of yours has a plan, or genclass.dev sells none here.");
+ out(` ${c.gray("Billing, in the browser")} ${c.cyan(billingUrl)}`);
+ out();
+ return 0;
+ }
+ if (offer.project) await projectPlan(offer, o, deps);
+ else {
+ planCard(offer, null);
+ if (o.yes || !interactive()) out(` ${c.gray(`Start ${clean(offer.plan.name, 20)}:`)} ${c.cyan(offer.checkoutUrl)}`);
+ else await checkout(offer, o, deps);
+ }
+ out();
+ return 0;
+}
+
+/**
+ * `genclass profile`: who is signed in, the plan of each app, and what can be done about it, as numbered choices:
+ * upgrade an app on Free, billing in the browser, sign out.
+ */
+export async function profile(o = {}, deps = {}) {
+ const s = await signedIn(o, deps, "profile");
+ 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 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 }));
+ 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)));
+ if (mine.length) {
+ 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));
+ out(` ${clean(p.name, 24).padEnd(24)} ${what}${p.token === state.app?.token ? c.gray(" this folder") : ""}`);
+ }
+ }
+ const choices = [];
+ 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", [])]);
+ out();
+ if (o.yes || !interactive()) {
+ for (const [label, cmd] of choices) {
+ out(` ${label}`);
+ out(` ${c.cyan(cmd)}`);
+ }
+ out();
+ return 0;
+ }
+ choices.forEach(([label], i) => out(` ${c.gray(String(i + 1).padStart(2))} ${label}`));
+ out();
+ const a = await typed(` ${PROMPT()} ${c.gray("a number ·")} ${c.bold("q")} ${c.gray("back")} `);
+ const pick = a !== null && /^\d+$/.test(a.trim()) ? choices[Number(a.trim()) - 1] : null;
+ if (pick) {
+ const r = await pick[2]();
+ if (typeof r === "number") return r;
+ }
+ out();
+ return 0;
+}
+
// ------------------------------------------------------------------------------------------------------ team
/** `genclass team`: who is on the plan. `genclass team invite` makes a single-use invite link. */
diff --git a/packages/cli/src/name.mjs b/packages/cli/src/name.mjs
index 69b14ada..cabfcd1e 100644
--- a/packages/cli/src/name.mjs
+++ b/packages/cli/src/name.mjs
@@ -36,7 +36,7 @@ export function shortNameWorks(env = process.env, platform = process.platform) {
}
/** `text` with every `npx genclass` said the short way. */
-export const shorten = (text) => String(text).replace(/\bnpx genclass\b/g, "genclass");
+export const shorten = (text) => String(text).replace(/(^|[^\w-]|\u001b\[[0-9;]*m)npx genclass\b/g, "$1genclass");
let short = false;
let wrapped = false;
diff --git a/packages/cli/src/state.mjs b/packages/cli/src/state.mjs
index 5eceff50..1daca57d 100644
--- a/packages/cli/src/state.mjs
+++ b/packages/cli/src/state.mjs
@@ -75,6 +75,7 @@ export function freeLimits(free) {
if (free?.apps) parts.push(`${free.apps} app${free.apps === 1 ? "" : "s"}`);
if (free?.historyDays) parts.push(`${free.historyDays} days of history`);
if (free?.suggestionsShown) parts.push(`your top ${free.suggestionsShown} suggestion${free.suggestionsShown === 1 ? "" : "s"}`);
+ if (free?.liveFixes) parts.push(`${free.liveFixes} live fixes a month`);
return parts.join(", ");
}
@@ -195,26 +196,6 @@ export async function readState(o = {}, deps = {}) {
human: "approve the sign-in in your browser",
});
- // 1b. the plan. An account on Free that can buy one here is asked once per project, right after the sign-in:
- // the paid plan first, Free for now as the small way on. An account with a plan, a server that sells nothing and
- // a signed-out person have nothing to choose, so the step does not show.
- const offer = me?.offer ?? null;
- const choseFree = notes.plan === "free";
- steps.push({
- id: "plan",
- title: "Plan",
- doing: "Choose a plan",
- state: !offer ? "optional" : choseFree ? "done" : "todo",
- detail: !me ? "" : !offer ? (me.plan ? `${me.plan.name} plan` : "") : choseFree ? `${offer.free.name} for now (limited) · ${offer.plan.name} is ${offer.price}` : `${offer.plan.name}, ${offer.price}`,
- why: offer ? `${offer.plan.name} is ${offer.price}. Or try ${offer.free.name} for now (limited).` : "The plan your account is on.",
- more: offer
- ? `${offer.plan.name} opens in your browser and is paid there, on Stripe. ${offer.free.name} keeps what finds, shows and fixes the problems${freeLimits(offer.free) ? `: ${freeLimits(offer.free)}` : ""}. You can change plan any time at genclass.dev/account/billing.`
- : "Plans and prices: genclass.dev/pricing.",
- run: "npx genclass setup",
- human: offer && !choseFree ? `choose a plan in your browser: ${offer.checkoutUrl}` : null,
- ...(offer ? { offer } : {}),
- });
-
// 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)
@@ -251,6 +232,61 @@ 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) : "";
+ 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 ?? 3} 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}.`
+ : `${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.`,
+ 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
+ after: "runtime",
+ });
+
// 3. the dashboard: the first page seen
steps.push({
id: "dashboard",
@@ -344,7 +380,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 } : null,
+ account: me ? { label: accountLabel(me.account), plan: me.plan, suggestions: me.suggestions, billingUrl: me.billingUrl, github: me.github, offer: me.offer ?? null } : null,
signedIn: !!me,
/** the account's apps as genclass.dev lists them ([] signed out) */
projects: me?.projects ?? [],
diff --git a/packages/cli/src/suggest/index.mjs b/packages/cli/src/suggest/index.mjs
index bf538116..1fd51efa 100644
--- a/packages/cli/src/suggest/index.mjs
+++ b/packages/cli/src/suggest/index.mjs
@@ -16,8 +16,9 @@ import { readCredentials } from "../../../runtime/bin/lib/account.mjs";
import { inspectProject } from "../../../runtime/bin/lib/init.mjs";
import { c, interactive, out, sym } from "../../../runtime/bin/lib/ui.mjs";
import { requestPlan } from "../api.mjs";
+import { copyText, findAgents, startAgent } from "../fix.mjs";
import { readNotes, saveNotes } from "../store.mjs";
-import { agree, clean, good, header, note, para, rule, spinner, typed, visible, warn, width, wrap } from "../tui.mjs";
+import { agree, clean, good, header, note, para, PROMPT, rule, spinner, typed, visible, warn, width, wrap } from "../tui.mjs";
import { AREAS, runChecks } from "./checks.mjs";
import { buildDigest, describeDigest } from "./digest.mjs";
import { collectRepoFacts } from "./repo.mjs";
@@ -93,6 +94,59 @@ function printItem(i, full) {
* @param {{ fetchFn?: typeof fetch, dns?: object, runGit?: Function }} [deps]
* @returns {Promise} the exit code: 0, or 1 when the site was named and could not be reached at all
*/
+/**
+ * The whole list as one prompt for a coding agent in the repository: each item with its reason, first step and
+ * prompt (the ones the plan shows), the rest by title, most important first, and how to check the work.
+ */
+export function suggestionsPrompt(items, url = null) {
+ const lines = [
+ `GenClass checked this repository${url ? ` and the live site ${url}` : ""} and found ${items.length} thing${items.length === 1 ? "" : "s"} to fix, most important first. Work through them one at a time, in this order. After each one, run \`npx genclass suggest --json\` and check that it no longer lists it.`,
+ "",
+ ];
+ for (const i of items) {
+ lines.push(`${i.n}. ${i.title}${i.evidence ? ` (found: ${i.evidence})` : ""}`);
+ if (i.open) {
+ if (i.why) lines.push(` Why: ${i.why}`);
+ if (i.step) lines.push(` First step: ${i.step}`);
+ if (i.prompt) lines.push(` ${i.prompt}`);
+ } else lines.push(" Look at what this check measures and fix it the usual way for this framework.");
+ lines.push("");
+ }
+ return lines.join("\n").trim();
+}
+
+/**
+ * After the list, on a terminal: hand every item to a coding agent in one go, as numbered choices (each agent
+ * installed here, copy the prompt, print it). The agent starts in this folder with the prompt and the terminal.
+ */
+async function handToAgent(items, url, cwd, o, deps, plan) {
+ const prompt = suggestionsPrompt(items, url);
+ const agents = (deps.agents ?? findAgents)(o.agent ?? null);
+ const locked = items.filter((i) => !i.open).length;
+ out();
+ rule();
+ out(` ${c.bold(`Hand ${items.length === 1 ? "it" : `all ${items.length}`} to your coding agent?`)}`);
+ if (locked) note(`On ${plan?.plan?.name ?? "Free"} it gets the steps for ${items.length - locked} of them and the rest by title. Every step for every one: npx genclass upgrade`);
+ const choices = [
+ ...agents.map((a) => [`Start ${a.name} with the list`, async () => {
+ out(` ${c.gray(sym.arrow)} Starting ${c.bold(a.name)} in this folder, with the list.`);
+ const code = await (deps.start ?? startAgent)(a, prompt, cwd);
+ if (code === null) {
+ warn(`${a.name} did not start. The prompt, to paste by hand:`);
+ out(prompt);
+ }
+ }]),
+ ["Copy the prompt", async () => ((await (deps.copy ?? copyText)(prompt)) ? good("Copied. Paste it into your coding agent.") : out(prompt))],
+ ["Print the prompt", async () => out(`\n${prompt}\n`)],
+ ];
+ choices.forEach(([label], i) => out(` ${c.gray(String(i + 1).padStart(2))} ${label}`));
+ if (!agents.length) note("No coding agent's command found here (claude, codex, cursor-agent, gemini).");
+ out();
+ const a = await typed(` ${PROMPT()} ${c.gray("a number ·")} ${c.bold("q")} ${c.gray("not now")} `);
+ const pick = a !== null && /^\d+$/.test(a.trim()) ? choices[Number(a.trim()) - 1] : null;
+ if (pick) await pick[1]();
+}
+
export async function suggest(o = {}, deps = {}) {
const st = inspectProject({ cwd: o.cwd });
const cwd = st.cwd;
@@ -209,6 +263,8 @@ export async function suggest(o = {}, deps = {}) {
out(` ${c.cyan("npx genclass suggest")}`);
note(`With a coding agent: paste an "Ask your agent" block, or tell it to run ${c.cyan("npx genclass suggest --json")} and work through the list.`);
}
+ // on a terminal: every item handed to a coding agent in one go (Claude Code, Codex, Cursor, Gemini CLI)
+ if (items.length && interactive() && !o.yes) await handToAgent(items, site?.reachable ? url : null, cwd, o, deps, plan);
out();
return url && site && !site.reachable && o.url ? 1 : 0;
}
diff --git a/packages/cli/test/actions.test.ts b/packages/cli/test/actions.test.ts
index 0a019bf5..e0699574 100644
--- a/packages/cli/test/actions.test.ts
+++ b/packages/cli/test/actions.test.ts
@@ -2,7 +2,7 @@
// workflow file, the agent configuration, cleaned text, git remotes and the command line.
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
-import { join } from "node:path";
+import { delimiter, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import { WORKFLOW_FILE as COLLECTOR_WORKFLOW } from "../../../telemetry-worker/src/github/workflow-file.js";
@@ -260,7 +260,9 @@ describe("genclass: the command line (src/cli.mjs, src/main.mjs)", () => {
it("the real entry names itself to the runtime CLI's modules: its commands say genclass, not the runtime package", () => {
const dir = tempDir();
viteApp(dir);
- const env = { ...process.env, NO_COLOR: "1", GENCLASS_HOST: "" };
+ // a machine with genclass installed would print the short name: the PATH here has none
+ const PATH = String(process.env.PATH ?? "").split(delimiter).filter((d) => d && !existsSync(join(d, "genclass"))).join(delimiter);
+ const env = { ...process.env, PATH, NO_COLOR: "1", GENCLASS_HOST: "" };
const main = fileURLToPath(new URL("../src/main.mjs", import.meta.url));
const status = execFileSync(process.execPath, [main, "status", "--cwd", dir], { env, encoding: "utf8" });
expect(status).toContain("GenClass");
diff --git a/packages/cli/test/home.test.ts b/packages/cli/test/home.test.ts
index 6c9ba659..f0c95bc4 100644
--- a/packages/cli/test/home.test.ts
+++ b/packages/cli/test/home.test.ts
@@ -5,7 +5,7 @@ import { join } from "node:path";
import { PassThrough } from "node:stream";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { COMMANDS as ALL, nearest, run } from "../src/cli.mjs";
-import { COMMANDS, home, menu, offerInstall, read, words } from "../src/home.mjs";
+import { COMMANDS, home, installShortName, menu, read, words } from "../src/home.mjs";
import { readPrefs, savePrefs, shorten, shortNameWorks } from "../src/name.mjs";
import { isolate, printed, tempDir, write } from "./helpers.js";
import { plainLines, tooWide } from "./stats.js";
@@ -54,6 +54,9 @@ describe("genclass: how the command is typed here (src/name.mjs)", () => {
expect(shorten("Run npx genclass fix, then npx genclass problems.")).toBe("Run genclass fix, then genclass problems.");
expect(shorten("npm install -g genclass")).toBe("npm install -g genclass");
expect(shorten("npx genclassic")).toBe("npx genclassic");
+ // in colour, the code just before it is not a letter of the word
+ expect(shorten("Every row: \u001b[36mnpx genclass dashboard --all\u001b[39m")).toBe("Every row: \u001b[36mgenclass dashboard --all\u001b[39m");
+ expect(shorten("xnpx genclass")).toBe("xnpx genclass");
// what it remembers for the person sits next to the sign-in, not in a project
expect(readPrefs()).toEqual({});
expect(savePrefs({ installAsked: true })).toBe(true);
@@ -61,31 +64,32 @@ describe("genclass: how the command is typed here (src/name.mjs)", () => {
expect(readPrefs()).toEqual({ installAsked: true, askTold: true });
});
- it("installing is offered once, as the command it runs, and only with a yes", async () => {
+ it("a run through npx on a terminal installs the command itself and says so; a failure waits a day; never in CI", async () => {
const before = process.env.CI;
delete process.env.CI;
try {
const ran: string[] = [];
- type("n");
- const no = await at58(async () => expect(await offerInstall({ install: async (v: string) => (ran.push(v), { ok: true, text: "" }) })).toBe("declined"));
- expect(tooWide(no)).toEqual([]);
- const flat = plainLines(no).join(" ").replace(/\s+/g, " ");
- expect(flat).toContain("Type just genclass from now on?");
- expect(flat).toContain("npm install -g genclass");
- expect(ran).toEqual([]);
- // asked once: the answer is remembered
- expect(await offerInstall({ install: async () => ({ ok: true, text: "" }) })).toBe("skipped");
- savePrefs({ installAsked: false });
- type("");
- const yes = await at58(async () => expect(await offerInstall({ install: async (v: string) => (ran.push(v), { ok: true, text: "" }), works: () => false })).toBe("installed"));
+ const yes = await at58(async () => expect(await installShortName({ interactive: () => true, install: async (v: string) => (ran.push(v), { ok: true, text: "" }), works: () => false })).toBe("installed"));
+ expect(tooWide(yes)).toEqual([]);
expect(ran).toHaveLength(1);
- expect(yes).toContain("Open a new terminal and type genclass.");
- savePrefs({ installAsked: false });
- type("");
- const failed = await at58(async () => expect(await offerInstall({ install: async () => ({ ok: false, text: "EACCES" }) })).toBe("failed"));
+ const flat = plainLines(yes).join(" ").replace(/\s+/g, " ");
+ expect(flat).toContain("Added. Open a new terminal window and type genclass.");
+ expect(flat).toContain("npm uninstall -g genclass takes it out.");
+ // a failure says how by hand, and is not tried again for a day
+ const failed = await at58(async () => expect(await installShortName({ interactive: () => true, install: async () => ({ ok: false, text: "EACCES" }), works: () => false })).toBe("failed"));
expect(plainLines(failed).join(" ").replace(/\s+/g, " ")).toContain("npm install -g genclass (some systems need sudo in front)");
+ expect(await installShortName({ interactive: () => true, install: async (v: string) => (ran.push(v), { ok: true, text: "" }), works: () => false })).toBe("skipped");
+ expect(await installShortName({ interactive: () => true, now: Date.now() + 25 * 60 * 60_000, install: async (v: string) => (ran.push(v), { ok: true, text: "" }), works: () => false })).toBe("installed");
+ expect(ran).toHaveLength(2);
+ // already there, no terminal, or CI: nothing runs
+ expect(await installShortName({ interactive: () => true, install: async (v: string) => (ran.push(v), { ok: true, text: "" }), works: () => true })).toBe("skipped");
+ expect(await installShortName({ interactive: () => false, install: async (v: string) => (ran.push(v), { ok: true, text: "" }), works: () => false })).toBe("skipped");
+ process.env.CI = "1";
+ expect(await installShortName({ interactive: () => true, install: async (v: string) => (ran.push(v), { ok: true, text: "" }), works: () => false })).toBe("skipped");
+ expect(ran).toHaveLength(2);
} finally {
- process.env.CI = before;
+ if (before === undefined) delete process.env.CI;
+ else process.env.CI = before;
}
});
});
@@ -108,6 +112,10 @@ describe("genclass: the home (src/home.mjs)", () => {
expect(read("npx genclass fix 2")).toEqual({ command: ["fix", "2"] });
expect(read("genclass problems")).toEqual({ command: ["problems"] });
expect(read("npx genclass dashbord")).toEqual({ unknown: "dashbord" });
+ // a number is the command listed with it
+ expect(read("1")).toEqual({ command: ["dashboard"] });
+ expect(read(String(COMMANDS.findIndex(([n]) => n === "profile") + 1))).toEqual({ command: ["profile"] });
+ expect(read("99")).toEqual({ unknown: "99" });
expect(read("npx genclass")).toEqual({ help: true });
// every command the command line has, including the runtime's own, is a command here and never a question
for (const cmd of ["connect", "init", "audit"]) expect(read(`${cmd} --name shop now`)).toEqual({ command: [cmd, "--name", "shop", "now"] });
diff --git a/packages/cli/test/server-account.test.ts b/packages/cli/test/server-account.test.ts
index 49f825e6..f35b9b4c 100644
--- a/packages/cli/test/server-account.test.ts
+++ b/packages/cli/test/server-account.test.ts
@@ -15,7 +15,7 @@ import { saveCredentials } from "../../runtime/bin/lib/account.mjs";
import { answerLines, ask, forget, marks } from "../src/ask.mjs";
import { askModel, fetchAccount, fetchConnections } from "../src/api.mjs";
import { signInAgain } from "../src/app.mjs";
-import { agentLink, apps, claudeAdd, connections, exportEvents, funnels, plan, team } from "../src/manage.mjs";
+import { agentLink, apps, claudeAdd, connections, exportEvents, freeLine, funnels, plan, profile, team, upgrade } from "../src/manage.mjs";
import { analytics } from "../src/views.mjs";
import { HOST, isolate, printed, tempDir } from "./helpers.js";
import { plainLines, tooWide } from "./stats.js";
@@ -96,21 +96,55 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => {
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
expect(f).toContain("T E A M");
- expect(f).toContain("$15 / app / month");
- expect(text).toContain(`${HOST}/account/billing?plan=team&start=1`);
+ 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(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" } } });
});
- it("Team: the price, when it renews, the apps paid for and any number of people; a sign-in from before still gets the plan's name and what to do", async () => {
+ it("Free says so after a command, with the way up; profile and upgrade name the app on Free and its plan page", async () => {
+ const { env, sql } = setup(LIVE);
+ const me = await account(env);
+ 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). Team has none, for $15 / project / month. Upgrade now: 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);
+ expect(tooWide(p.text)).toEqual([]);
+ 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("Apps and their plans Shop Free");
+ expect(f).toContain("Upgrade Shop to Team $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` });
+ // upgrade: the paid plan's card and the app's plan page
+ 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`);
+ // 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);
+ expect(flat((await said(() => upgrade(me.o, me.deps))).text)).toContain("Nothing to upgrade here");
+ });
+
+ it("Team: the price, when it renews, the seats; a sign-in from before still gets the plan's name and what to do", async () => {
const { env, sql } = setup(LIVE);
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 Team $15 / app / month");
+ expect(f).toContain("Plan Team $15 / project / month");
expect(f).toContain("Renews 2026-12-13");
- // Team is sold per app (2026-11-12): the quantity paid for (3) is the apps; people have no limit
- expect(f).toContain("Apps 1 of 3");
+ // Team is per project, everyone on it included
expect(f).toContain("People 1 • no limit");
+ expect(f).toContain("Apps and their plans Shop Team");
+ expect(f).toContain("Apps 1 • no limit");
expect(f).toContain("Includes visitor analytics, alerts to Slack, Sentry and others, the stored events as a file, private repositories");
expect(f).not.toContain("T E A M");
// a sign-in the real server calls current is left alone by `genclass login`
@@ -163,10 +197,12 @@ 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));
- expect(over.code).toBe(1);
- expect(flat(over.text)).toContain("npx genclass plan");
+ // a fourth app is made, and needs a plan of its own
+ expect(over.code).toBe(0);
+ expect(flat(over.text)).toContain("Free covers 3 apps: this one needs a paid plan.");
+ 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", "Shop", "Third"]);
+ 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"]);
});
});
diff --git a/packages/cli/test/state.test.ts b/packages/cli/test/state.test.ts
index 7b9d3acf..925bdceb 100644
--- a/packages/cli/test/state.test.ts
+++ b/packages/cli/test/state.test.ts
@@ -217,7 +217,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", "plan", "runtime", "dashboard", "workflow", "github-app", "agent", "suggestions"]);
+ expect(j.steps.map((x) => x.id)).toEqual(["account", "runtime", "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);
@@ -338,7 +338,7 @@ describe("genclass: the plan step (src/state.mjs, src/actions.mjs choosePlan, sr
};
const plain = (t: string) => t.replace(/\u001b\[[0-9;]*m/g, "");
- it("an account on Free that can buy is asked right after the sign-in; one with a plan, or signed out, is not", async () => {
+ 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 () => {
const dir = tempDir();
viteApp(dir);
// signed out: nothing to choose yet
@@ -346,10 +346,12 @@ describe("genclass: the plan step (src/state.mjs, src/actions.mjs choosePlan, sr
signIn();
const offered = fakeServer({ "GET /api/cli/overview": { json: overview({ offer: OFFER }) } });
const s = await readState({ cwd: dir }, { runGit: noGit, fetchFn: offered.fetchFn });
- expect(s.steps.map((x) => x.id).slice(0, 3)).toEqual(["account", "plan", "runtime"]);
- expect(s.next?.id).toBe("plan");
+ // 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 step = s.steps.find((x) => x.id === "plan")!;
- expect(step).toMatchObject({ state: "next", doing: "Choose a plan", detail: "Team, $15 / developer / month", why: "Team is $15 / developer / month. Or try Free for now (limited)." });
+ 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
@@ -376,11 +378,42 @@ describe("genclass: the plan step (src/state.mjs, src/actions.mjs choosePlan, sr
// 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 })).next?.id).toBe("plan");
+ 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");
});
+ 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 () => {
+ 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 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);
+ // 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`);
+ // 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" });
+ });
+
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/cli/test/suggest.test.ts b/packages/cli/test/suggest.test.ts
index 7d20498c..ec82f99c 100644
--- a/packages/cli/test/suggest.test.ts
+++ b/packages/cli/test/suggest.test.ts
@@ -8,7 +8,7 @@ import { readState } from "../src/state.mjs";
import { readNotes } from "../src/store.mjs";
import { CHECKS, runChecks } from "../src/suggest/checks.mjs";
import { buildDigest, describeDigest } from "../src/suggest/digest.mjs";
-import { FREE_SHOWN, layOut, siteAddress, suggest } from "../src/suggest/index.mjs";
+import { FREE_SHOWN, layOut, siteAddress, suggest, suggestionsPrompt } from "../src/suggest/index.mjs";
import { HOST, isolate, printed, tempDir, viteApp, write } from "./helpers.js";
isolate();
@@ -230,3 +230,19 @@ describe("genclass suggest: end to end (src/suggest/index.mjs header)", () => {
expect(CHECKS.length).toBeLessThanOrEqual(60);
});
});
+
+describe("genclass suggest: every item to a coding agent in one prompt (src/suggest/index.mjs suggestionsPrompt)", () => {
+ it("each item the plan shows with its reason, step and prompt, the rest by title, in order, with how to check", () => {
+ const items = [
+ { n: 1, title: "No sitemap", evidence: "GET /sitemap.xml 404", open: true, why: "Search engines miss pages.", step: "Add a sitemap.", prompt: "Add a sitemap.xml that lists every page." },
+ { n: 2, title: "No error page", evidence: null, open: false },
+ ];
+ const p = suggestionsPrompt(items, "https://shop.example");
+ expect(p).toContain("GenClass checked this repository and the live site https://shop.example and found 2 things to fix");
+ expect(p).toContain("run `npx genclass suggest --json`");
+ expect(p).toContain("1. No sitemap (found: GET /sitemap.xml 404)\n Why: Search engines miss pages.\n First step: Add a sitemap.\n Add a sitemap.xml that lists every page.");
+ expect(p).toContain("2. No error page\n Look at what this check measures");
+ expect(p.indexOf("1. No sitemap")).toBeLessThan(p.indexOf("2. No error page"));
+ expect(suggestionsPrompt([items[0]!])).toContain("GenClass checked this repository and found 1 thing to fix");
+ });
+});
diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md
index 1502b16f..63041bbd 100644
--- a/telemetry-worker/README.md
+++ b/telemetry-worker/README.md
@@ -5,7 +5,7 @@ Cloudflare Worker that receives the default-on diagnostics batches from `@gencla
a token and a private dashboard on genclass.dev, and that holds the optional accounts (Google, an email and a
password, or GitHub, with passkeys as extra security) which keep several apps in one place and let `npx @genclass/runtime@beta login` connect an app
([Accounts](#accounts)). There are three plans (since 2026-11-11): Free, the dashboard's problems for one person;
-Team, sold on the site per app (per developer until 2026-11-12), with visitor analytics, forwarding, export, 90 days of history, private
+Team, sold on the site per project (one subscription per app, since 2026-11-12; per developer before), with visitor analytics, forwarding, export, 90 days of history, private
repositories for the GitHub App and members ([Teams](#teams)); and Enterprise, by agreement, with your own
classifier, which GenClass trains ([Paid plans](#paid-plans)). There is no trial.
@@ -542,15 +542,17 @@ the free features"; one thing stays free, finding, seeing and fixing the problem
the dashboard and the API say which plan has them and where the plans are (403 with `upgrade_url`; the stats carry
`locked.analytics`). What is collected and kept does not change with the plan: every app's batches are stored and
counted, visitor analytics included, and kept 90 days, so an upgrade shows what was counted before it.
-- **Team** (US$15 an app a month, `per_app` since 2026-11-12, US$15 a seat before; lookup key
+- **Team** (US$15 a project a month, `per_project` since 2026-11-12, US$15 a seat on 2026-11-11; lookup key
`genclass_team_monthly`, tax code `txcd_10103001`, software as a service, business use; standard Checkout, not
- Managed Payments, with the "Add promotion code" field): everything in Free, 90 days shown, visitor analytics,
- forwarding, export, the GitHub App on private repositories (`github_private`) and any number of members
- ([Teams](#teams)). The subscription's quantity is the apps it covers (`src/plans.ts` appsOf, stored in
- `accounts.seats`): no app is created past it (a 409 that says to add apps in Manage billing), and when the account
- holds more apps than it pays for, only its first apps by creation have Team's entitlements, the others Free's
- (`projectAccess`, `withinPaidApps`: the dashboard, export, forwarding and ingest's forwarding and export copy).
- A grant of Team has no app limit. Promotion codes are made in the Stripe dashboard (Product catalog, Coupons).
+ Managed Payments, with the "Add promotion code" field): everything in Free, 90 days shown, unlimited live fixes,
+ visitor analytics, forwarding, export, the GitHub App on private repositories (`github_private`) and any number of
+ members ([Teams](#teams)). Each app has its own subscription of quantity 1, bought on its plan page
+ (`/account/billing?project=`), its metadata naming the app (`genclass_project`); the webhook writes it to that
+ app's row (`src/stripe.ts` applyProjectSubscription, migration 0016). A subscription of the whole account from
+ before (per seat) keeps covering every app of its account, and a grant of a plan sold per project was copied onto
+ the apps the account had. Free covers an account's first 3 apps and 100 problems fixed live a month for each
+ (`src/usage.ts`; past it ingest's reply tells the runtime to observe only until the month ends); a 4th app on
+ Free is locked until it has a plan. Promotion codes are made in the Stripe dashboard (Product catalog, Coupons).
- **Enterprise** (no price: agreed by email with the contact address): everything in Team, a year of history, your
own classifier (`custom_model`: a person from GenClass trains a model on the app's traffic, bug reports or labelled
examples and ships it as private model files), and
@@ -617,9 +619,9 @@ their link opens the dashboard (24 hours, 7 and 30 days).
`src/team.ts` (data) and `src/team-routes.ts` (routes); tables `team_members` and `team_invites` (migration 0014).
- **Who is in.** A team is an account and the accounts it added as members. How many people an account holds comes
- from its plan (`src/plans.ts` accessOf, `seats`): Free 1; Team (per app since 2026-11-12) no limit; a plan sold
+ from its plan (`src/plans.ts` accessOf, `seats`): Free 1; Team (per project since 2026-11-12) no limit; a plan sold
per seat the seats its subscription pays for (`accounts.seats`, the subscription item's quantity, written by the
- webhook with the plan; `test/team.test.ts` runs it on `test/fixtures/plans-per-seat.json`); a plan GenClass grants,
+ webhook with the plan; `test/team.test.ts` runs it on `test/fixtures/plans-v3-seats.json`); a plan GenClass grants,
and every account on a server with `PLAN_GATES = "open"`, no limit. The owner is the first seat, then members in
the order they joined. A member beyond the seats (fewer seats, or the plan ended) keeps the row and loses the
access until there is a seat again: no billing change removes anyone.
@@ -809,10 +811,12 @@ does about it.
| `POST /api/billing/checkout` | `{ plan: "", quantity? }` (a plan priced above 0: `team`; `quantity` (or `seats`, read the same) 1 to 200 for a plan sold by quantity becomes the subscription's quantity, which the buyer can change on Stripe's page: the apps for Team, default the apps the account owns, or the seats of a per-seat plan, default 1; Checkout takes promotion codes; Enterprise has no Checkout), browser session and our `Origin` (403 otherwise, also for a CLI token). Creates the account's own Stripe customer once (email, description `GenClass account`, metadata `genclass=1` and `genclass_account`); never looks one up by email. A stored customer is checked first (`GET /v1/customers/`): its metadata must name the account, and every subscription on it must be GenClass's, else 409 with the contact address, nothing opened, and a `shared_customer` notice ("Stripe object ownership" below). Then it asks Stripe, not the database, whether that customer already has a GenClass subscription: one on this plan is a 409, one whose last payment failed (`past_due`, `unpaid`) is a 409 pointing to Manage billing, two at once (a switch still going through) or a paused one is a 409, and an active one on another paid plan is a switch: after the same invoice check as Manage billing, `{ url, switch: true }` of a Checkout for the new plan whose session and subscription carry `genclass_switch_from=` (success URL `...?done=1&to=`). Otherwise `{ url }` of a Checkout Session in subscription mode for that customer and the plan's lookup key (metadata `genclass=1`, `genclass_account`, `genclass_plan`; the subscription gets `genclass` and `genclass_account` and flexible billing mode; `client_reference_id` = account id; success `https://genclass.dev/account/billing?done=1`), with `managed_payments[enabled]=true` for a plan marked `managed_payments` (none today) when `MANAGED_PAYMENTS` is `on`. GenClass's open Checkout page of the account for the same plan (and the same switch) is reused, its other open pages are closed first, and any other open page is left alone. 503 unless the account can buy (above). |
| `POST /api/billing/portal` | browser session and `Origin`; `{ url }` of a Billing Portal session (cancel at period end, card, email and tax id, invoices; no plan switch, which is a new Checkout) on GenClass's own portal configuration: the one whose id `STRIPE_PORTAL_CONFIGURATION` (live) or `STRIPE_PORTAL_CONFIGURATION_TEST` (test) names, fetched by that id and checked (metadata `genclass=1`, active, not the default; cached 10 minutes), never the account's default one: without it, 503 ("run scripts/stripe-setup.mjs"). The portal acts on everything on the customer, so first the customer must be the account's (metadata), every subscription on it GenClass's, and each of its latest 100 invoices for a GenClass subscription; otherwise 409 with the contact address, no session, and a `shared_customer` notice. 409 before a first purchase, and when Stripe no longer has the customer (the stored ids are then cleared). |
| `POST /api/billing/webhook` | Stripe only. The raw body must carry a valid `Stripe-Signature` (HMAC-SHA256 of `t.body`, any `v1`, within 5 minutes) under `STRIPE_WEBHOOK_SECRET` with a live key or `STRIPE_WEBHOOK_SECRET_TEST` with a test key; else 400. An event signed with the other mode's secret, or whose `livemode` is not the key's mode, is answered 200 and ignored (a test endpoint left over after going live). Handles `customer.subscription.created`, `.updated` and `.deleted`, `invoice.paid`, `charge.refunded` and `charge.dispute.created`, and subscribes to nothing else: they arrive for the whole Stripe account, so first, with no Stripe call, the customer must be in `accounts.stripe_customer_id`, and the subscription must look like GenClass's (the one recorded for the account, metadata `genclass=1` on it or copied onto the invoice, or a `genclass_` lookup key on a price). From an invoice only its subscription id is read; a refund or dispute of a GenClass customer's charge sends the owner `charge_refunded` or `charge_disputed` (ids and amounts; a dispute names no customer, so its charge is read first). Then it fetches the subscription (its prices' products, its customer and its latest invoice expanded), which must be provably GenClass's (every price a `genclass_` lookup key on a `genclass_plan` product, or the subscription already recorded for the account) and the account's (its customer's `genclass_account` names the account), and writes the plan, status, `past_due_since`, period end (when it ended, for one cancelled at once), cancel flag and mode. A subscription the account has not been granted yet is written only once its latest invoice is paid (or asks for nothing), never on `active` alone; another subscription than the account's current one never overwrites a live plan unless it was chosen as below. When a subscription starts billing (`created`, `updated` with a status change, or `invoice.paid`), if the customer has two GenClass subscriptions that bill and are paid for (a switch, or two tabs paid), the account keeps one (a switch's new plan; otherwise a live one, then the plan listed later in `billing/plans.json`, then the newest): it is written to the account first, then each other one is refunded to its payment method (a switch's old plan: its unused time; a duplicate: its latest payment in full; once, by the metadata `genclass_refund`) and cancelled at once with no proration, and `NOTIFY_WEBHOOK` gets `plan_switch` or `duplicate_subscription` with the refunds. A new subscription that is never paid (`incomplete`, `incomplete_expired`, or `active` with its invoice unpaid) changes nothing. The end of the subscription recorded for the account is applied even when its customer's metadata is gone (Stripe erasing a buyer's data on request). A purchase of a plan with `custom_model` (Individual) is recorded (`custom_requests`, `NOTIFY_WEBHOOK`, naming the plan) only when Stripe reports that plan's price paid: the subscription's latest invoice is paid and has a positive line on it (a first invoice, a switch included, seen on `invoice.paid` or the subscription's own events). Every other event (another business's subscriptions, in a shared account) is answered 200, logged as `stripe webhook: ignored: `, not stored, and nothing about it is fetched. The ids of events about GenClass customers are kept 30 days (`billing_events`), so a redelivery changes nothing; a failure answers 500 and Stripe retries. |
-| `GET /api/billing` | browser session: `{ plan, subscription_status, current_period_end, cancel_at_period_end, on_sale, access, seats, apps }` (`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, `apps`: the apps it may hold (Team: the apps paid for), null for no limit |
+| `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` | the plan, its renewal or end date, the plans side by side (Free, Team and Enterprise), the apps (or 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 /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. |
+| `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 |
| `PUT /api/account/projects//integrations/` | browser session and `Origin`, a plan with forwarding (Team and Enterprise; else 403 with `upgrade_url`), `INTEGRATIONS_KEY` set (503 otherwise). `kind` is `sentry` `{dsn}` (a `sentry.io` host), `posthog` `{key, host}`, `datadog` `{api_key, site}`, `slack` `{url}` or `webhook` `{url}` (https, public host name, default port, not genclass.dev or workers.dev); a new webhook gets a signing secret, returned once as `secret` |
| `PATCH`, `DELETE` same path; `POST ...//test` | turn a destination on or off (`{enabled}`), remove it, or send it one test record (502 with the reason when it fails). A test record counts against the app's 60 records an hour (429 when they are used up) |
diff --git a/telemetry-worker/migrations/0016_project_plans.sql b/telemetry-worker/migrations/0016_project_plans.sql
new file mode 100644
index 00000000..c6b8bd57
--- /dev/null
+++ b/telemetry-worker/migrations/0016_project_plans.sql
@@ -0,0 +1,30 @@
+-- Plans per project (billing/plans.json version 4, 2026-11-12): each app has its own plan. Team is $15 a month per
+-- project, everyone on it included; Free covers an account's first 3 projects and 100 problems fixed live a month
+-- per project. Additive, and one copy of rows that exist:
+-- - the paid plan of a project: written by the webhook from a subscription whose metadata names the project
+-- (src/stripe.ts applyProjectSubscription); an account-level subscription from before keeps covering every app of
+-- its account (src/plans.ts projectAccess), so nobody who pays loses anything;
+-- - GenClass's grant per project: every app of an account that had a grant gets the same grant (the owner's choice,
+-- 2026-11-12: "every project they have"); apps added later choose a plan like everyone else's;
+-- - when Free was chosen for a project on its plan page ("Try for free (limited)"): the genclass command's plan
+-- step waits for this or a payment;
+-- Live fixes per project and month need no table: they are read from the daily counters (src/usage.ts).
+-- Apply before deploying the worker that reads them:
+-- npx --yes wrangler@4 d1 migrations apply genclass-dashboard --remote
+
+ALTER TABLE projects ADD COLUMN plan TEXT; -- the paid plan's id; NULL: none
+ALTER TABLE projects ADD COLUMN subscription_id TEXT;
+ALTER TABLE projects ADD COLUMN subscription_status TEXT;
+ALTER TABLE projects ADD COLUMN current_period_end TEXT;
+ALTER TABLE projects ADD COLUMN cancel_at_period_end INTEGER;
+ALTER TABLE projects ADD COLUMN stripe_mode TEXT; -- the Stripe mode the subscription belongs to
+ALTER TABLE projects ADD COLUMN comp_until TEXT; -- GenClass's own grant for this app, until then
+ALTER TABLE projects ADD COLUMN comp_plan TEXT; -- the plan that grant gives
+ALTER TABLE projects ADD COLUMN plan_chosen TEXT; -- when Free was chosen on the project's plan page
+CREATE INDEX projects_subscription ON projects (subscription_id);
+
+UPDATE projects
+SET comp_until = (SELECT a.comp_until FROM accounts a WHERE a.id = projects.account_id),
+ comp_plan = (SELECT a.comp_plan FROM accounts a WHERE a.id = projects.account_id)
+WHERE account_id IN (SELECT id FROM accounts WHERE comp_until IS NOT NULL);
+
diff --git a/telemetry-worker/migrations/0017_plan_feedback.sql b/telemetry-worker/migrations/0017_plan_feedback.sql
new file mode 100644
index 00000000..2dae325c
--- /dev/null
+++ b/telemetry-worker/migrations/0017_plan_feedback.sql
@@ -0,0 +1,15 @@
+-- Why Free was chosen for an app (the owner's call, 2026-11-12): the plan page asks "What's stopping you from
+-- paying?" before an app goes on with Free, with a few reasons to pick and an optional note (at most 500 characters).
+-- One row per choice; the owner reads them to price and build Team. Nothing else about the person is stored here.
+-- Apply before deploying the worker that writes it:
+-- npx --yes wrangler@4 d1 migrations apply genclass-dashboard --remote
+
+CREATE TABLE plan_feedback (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ account_id TEXT NOT NULL,
+ project_id TEXT NOT NULL,
+ reason TEXT NOT NULL, -- one of FREE_REASONS (src/billing.ts)
+ note TEXT, -- what they wrote, if anything
+ created TEXT NOT NULL
+);
+CREATE INDEX plan_feedback_created ON plan_feedback (created);
diff --git a/telemetry-worker/src/account-pages.ts b/telemetry-worker/src/account-pages.ts
index d952de80..f4272fd7 100644
--- a/telemetry-worker/src/account-pages.ts
+++ b/telemetry-worker/src/account-pages.ts
@@ -1067,7 +1067,7 @@ export function newProjectPage(nonce: string, v: NewProjectView): string {
ev.preventDefault(); err.textContent = "";
if (!name.value.trim()) { err.textContent = "Give the project a name."; name.focus(); return; }
gcBusy(b, true, "Creating...");
- gcPost("/api/account/projects", { name: name.value }).then(function(j){ location.href = "/account/apps/" + encodeURIComponent(j.project.id) + "#connect"; }, function(e){ err.textContent = gcWhy(e); gcBusy(b, false); });
+ gcPost("/api/account/projects", { name: name.value }).then(function(j){ location.href = "/account/billing?project=" + encodeURIComponent(j.project.id) + "&new=1"; }, function(e){ err.textContent = gcWhy(e); gcBusy(b, false); });
});
})();`;
return studioPage(nonce, {
diff --git a/telemetry-worker/src/auth.ts b/telemetry-worker/src/auth.ts
index fbf0c748..e546be78 100644
--- a/telemetry-worker/src/auth.ts
+++ b/telemetry-worker/src/auth.ts
@@ -38,6 +38,7 @@
// - Nothing is logged; the client IP is only a rate-limit key (hashed), never stored.
// Routes: see telemetry-worker/README.md ("Accounts").
+import { liveFixUsage } from "./usage.js";
import type { Env, HandleOptions } from "./collector.js";
import {
accountPage,
@@ -80,7 +81,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, appLimit, appLimitBody, firstPlanWith, hasAnalytics, hasDashboard, keepDays, pickRange, projectAccess, viewRanges } from "./plans.js";
+import { accountAccess, accountBilling, canBuy, firstPlanWith, FREE, hasAnalytics, hasDashboard, keepDays, PAID_PLANS, pickRange, projectAccess, viewRanges } from "./plans.js";
import { activeOwners, forgetTeam, membershipsOf } from "./team.js";
import { cancelForDeletion } from "./stripe.js";
import {
@@ -1571,12 +1572,14 @@ const appCount = async (c: C, accountId: string): Promise =>
Number((await c.db.prepare("SELECT COUNT(*) AS n FROM projects WHERE account_id = ?").bind(accountId).first<{ n: number }>())?.n ?? 0);
const tooManyApps = (): Response => fail(409, `an account can hold at most ${MAX_APPS_PER_ACCOUNT} apps`);
-/** The 409 when one more app would pass the plan's limit (src/plans.ts) or the abuse cap, else null. */
+/**
+ * The 409 when one more app would pass the abuse cap, else null. Plans are per project (billing/plans.json version 4):
+ * an app past the ones Free covers is created all the same and waits for a plan (src/plans.ts projectAccess), so
+ * adding a project and choosing its plan is one flow.
+ */
async function overAppLimit(c: C, accountId: string): Promise {
const n = await appCount(c, accountId);
if (n >= MAX_APPS_PER_ACCOUNT) return tooManyApps();
- const lim = await appLimit(c.db, c.env, accountId, c.now);
- if (lim.max !== null && n >= lim.max) return json(409, appLimitBody(lim, c.cfg.origin));
return null;
}
@@ -1688,6 +1691,11 @@ async function deleteProject(c: C, id: string): Promise {
if (body.confirm !== "delete") return fail(400, 'send { "confirm": "delete" } to delete this project');
const p = await ownedProject(c, a.account.id, id);
if (!p) return fail(404, "not found");
+ // a project that is paid for is not deleted while its subscription still bills: nobody pays for an app that is gone
+ const paid = await c.db.prepare("SELECT subscription_status FROM projects WHERE id = ?").bind(id).first<{ subscription_status: string | null }>();
+ if (paid?.subscription_status && ["active", "trialing", "past_due", "unpaid"].includes(paid.subscription_status)) {
+ return fail(409, `${p.name} has a paid plan: cancel it in Manage billing (${c.cfg.origin}/account/billing) first, then delete the project`);
+ }
await c.db.batch([
c.db.prepare("DELETE FROM integrations WHERE project_id = ?").bind(id),
c.db.prepare("DELETE FROM forward_log WHERE project_id = ?").bind(id),
@@ -1909,7 +1917,8 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E
}
const p = await createProject(c.db, name, c.now, a.account.id);
const to = `/account/apps/${p.id}#connect`;
- return json201 ? json(201, { url: to, copiedUrl: to, token: p.token, command: setupCommand(p.token) }) : redirect(to, [], 303);
+ // the form's way on is the new app's plan page first (it then goes on to the app's setup)
+ return json201 ? json(201, { url: to, copiedUrl: to, token: p.token, command: setupCommand(p.token) }) : redirect(`/account/billing?project=${encodeURIComponent(p.id ?? "")}&new=1`, [], 303);
}
case "/signup":
case "/login": {
@@ -2055,12 +2064,13 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E
if (!p) return fail(404, "not found");
// the dashboard comes with the plan of the app's owner (every plan includes it; src/plans.ts projectAccess)
const access = await projectAccess(c.db, p, env, c.now);
- if (!hasDashboard(access)) return lockedJson(c);
+ if (!hasDashboard(access)) return lockedJson(c, access, p.id);
// the ranges that plan shows; a longer one is answered with the longest it shows
const range = pickRange(url.searchParams.get("range"), viewRanges(access.plan));
const s = await stats(c.db, p, range, c.now);
+ const usage = await liveFixUsage(c.db, p.token, access.plan, c.now).catch(() => null);
// visitor analytics are left out for a plan without them, and counted all the same (src/dashboard.ts stripAnalytics)
- return json(200, hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: `${c.cfg.origin}/account/billing` }));
+ return json(200, { ...(hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: `${c.cfg.origin}/account/billing` })), usage, plan: { id: access.plan.id, name: access.plan.name, source: access.source }, plan_url: `${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}` });
}
const fun = /^\/api\/account\/projects\/([^/]+)\/funnels$/.exec(path);
if (fun) {
@@ -2069,8 +2079,8 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E
if (a instanceof Response) return a;
const p = await ownedProject(c, a.account.id, fun[1]!);
if (!p) return fail(404, "not found");
- const access = await accountAccess(c.db, a.account.id, env, c.now);
- if (!hasDashboard(access)) return lockedJson(c);
+ const access = await projectAccess(c.db, p, env, c.now);
+ if (!hasDashboard(access)) return lockedJson(c, access, p.id);
if (!hasAnalytics(access)) return upgradeJson(c, "funnels are part of visitor analytics", "analytics");
return putFunnels(c.req, c.db, p.token);
}
@@ -2094,6 +2104,12 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E
const mine = p.account_id === a.account.id;
const apps = (await accountProjects(c, a.account.id)).map((x) => ({ id: x.id!, name: x.name }));
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)) {
+ 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!)}`);
+ }
const base = {
account: { apps, current: p.id!, email: a.account.email ?? (a.account.github_login ? `@${a.account.github_login}` : null), plan: access.plan.name },
id: p.id!,
@@ -2124,8 +2140,15 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E
return null;
}
-/** The answer to a stats request for an app whose plan has no dashboard (a plan file without it). */
-export function lockedJson(c: { cfg: AuthConfig; env: Env }): Response {
+/**
+ * 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.
+ */
+export function lockedJson(c: { cfg: AuthConfig; env: Env }, access?: { locked?: string } | null, projectId?: string | null): Response {
+ if (access?.locked === "free_projects" && 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 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 61fbca61..6de2ad70 100644
--- a/telemetry-worker/src/billing-pages.ts
+++ b/telemetry-worker/src/billing-pages.ts
@@ -1,5 +1,5 @@
// HTML for the paid plans: /account/billing (the plan, its renewal or end, the plans side by side, Upgrade, Switch and
-// Manage billing buttons, the apps (or seats) of a plan sold by quantity, and the team: members, invite links and the teams the
+// Manage billing buttons, the seats of a plan sold per seat, and the team: members, invite links and the teams the
// account is a member of; src/team.ts) and the plan-dependent sections of an owned app's dashboard (forwarding and export, and the
// note on the time ranges). A switch between two paid plans is a new Checkout (src/billing.ts checkout); back from it
// the page waits for the new plan. Same shell, tokens and rules as the account pages; server-rendered text goes through esc(),
@@ -22,8 +22,7 @@ import {
GRACE_DAYS,
keepDays,
LIVE_STATUSES,
- MAX_QUANTITY,
- unitOf,
+ MAX_SEATS,
PAID_PLANS,
PAST_DUE_GRACE_DAYS,
RETIRED_PLAN_IDS,
@@ -56,9 +55,9 @@ const switchable = (): string | null => (PAID_PLANS.length > 1 ? PAID_PLANS.map(
export interface BillingView {
account: { id: string; email: string | null; githubLogin: string | null };
+ /** Every app with its plan (plans are per project since billing/plans.json version 4). */
+ projects?: { id: string; name: string; access: Access }[];
apps: { id: string; name: string }[];
- /** How many of those apps the account owns (the others are its teams'): what a per_app plan counts. */
- ownApps?: number;
billing: Billing;
/** What the account may use now: its paid plan or GenClass's grant (src/plans.ts accessOf). */
access: Access;
@@ -148,13 +147,10 @@ function statusLine(v: BillingView, plan: Plan): string {
if (b.subscription_status === "trialing") {
return `You are on the ${name} plan, in a trial until ${day(b.current_period_end)}. When it ends your card is charged for the first month (${esc(priceText(plan))}), unless you cancel before in Manage billing.`;
}
- const n = plan.per_seat === true ? v.access.seats : plan.per_app === true ? v.access.apps : null;
- const unit = unitOf(plan);
- if (unit && n !== null) {
+ if (plan.per_seat === true && v.access.seats !== null) {
+ const n = v.access.seats;
const total = (plan.price_usd_cents.monthly ?? 0) * n;
- const own = v.ownApps ?? v.apps.length;
- const over = unit === "app" && own > n ? ` You have ${own} apps: ${own - n === 1 ? "the one made last has" : `the ${own - n} made last have`} the Free plan's features until you add apps.` : "";
- return `You are on the ${name} plan for ${n} ${unit}${n === 1 ? "" : "s"}, ${esc(priceText(plan))} ($${total % 100 ? (total / 100).toFixed(2) : String(total / 100)} a month). It renews on ${day(b.current_period_end)}. Add or remove ${unit}s in Manage billing.${over}`;
+ return `You are on the ${name} plan with ${n} seat${n === 1 ? "" : "s"}, ${esc(priceText(plan))} ($${total % 100 ? (total / 100).toFixed(2) : String(total / 100)} a month). It renews on ${day(b.current_period_end)}. Add or remove seats in Manage billing.`;
}
return `You are on the ${name} plan, ${esc(priceText(plan))}. It renews on ${day(b.current_period_end)}.`;
}
@@ -168,14 +164,17 @@ function planColumn(v: BillingView, p: Plan, current: Plan): string {
const waiting = !!awaited(v, current);
const pick = !cur && v.picked === p.id && v.live && !waiting;
// the first paid plan's button is the strong one, unless another was picked on the pricing page
- // a plan sold by quantity asks how many first: apps (the apps the account holds) or people (the owner is one);
- // Stripe's page shows it and lets them change it
- const unit = unitOf(p);
- const start = unit === "app" ? Math.min(MAX_QUANTITY, Math.max(1, v.ownApps ?? v.apps.length)) : Math.max(1, v.team?.used ?? 1);
- const seats = unit
- ? ``
- : "";
- const buy = `${seats}`;
+ // a plan sold per seat asks how many people first (the owner is one); Stripe's page shows it and lets them change it
+ const seats =
+ p.per_seat === true
+ ? ``
+ : "";
+ // a plan sold per project is bought on that project's own page, from the list below
+ const buy = p.per_project === true
+ ? v.projects?.length
+ ? `Choose a project`
+ : 'Add a project first: run npx genclass in your app.'
+ : `${seats}`;
const talk = `Talk to us`;
let act = "";
if (cur) act = `${v.access.source === "comp" ? "Set up by GenClass" : v.access.source === "open" ? "This server" : "Your plan"}`;
@@ -188,6 +187,196 @@ function planColumn(v: BillingView, p: Plan, current: Plan): string {
return `
${esc(p.name)}
${esc(priceText(p))}
${esc(p.summary)}
${features}
${act}
`;
}
+/** The account's projects, each with its plan and the way to change it (its own page). */
+function projectsSection(v: BillingView): string {
+ if (!v.projects?.length) return "";
+ const rows = v.projects
+ .map((p) => {
+ const a = p.access;
+ const what = a.locked
+ ? `Needs a plan`
+ : a.plan.id === "free"
+ ? "Free"
+ : `${esc(a.plan.name)}${a.source === "comp" ? " (set up by GenClass)" : ""}`;
+ const act = a.source === "paid" ? `See plan` : `${a.locked ? "Choose a plan" : "Upgrade"}`;
+ return `
${esc(p.name)}
${what}
${act}
`;
+ })
+ .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.
+
Project
Plan
${rows}
+`;
+}
+
+const FREE_APPS = PLANS[0]?.limits.apps ?? 3;
+
+/** The popup's reasons (src/billing.ts FREE_REASONS, kept in step by a test): [value, the words shown]. */
+const FREE_REASON_WORDS: readonly (readonly [string, string])[] = [
+ ["price", "It costs too much"],
+ ["trying", "I'm only trying GenClass out"],
+ ["approval", "I need to ask my team or my boss"],
+ ["feature", "It's missing something I need"],
+ ["unsure", "I'm not sure it works for my app yet"],
+ ["other", "Something else"],
+];
+export const FREE_REASONS_SHOWN = FREE_REASON_WORDS;
+
+export interface ProjectPlanView {
+ account: { id: string; email: string | null; githubLogin: string | null };
+ apps: { id: string; name: string }[];
+ project: { id: string; name: string };
+ access: Access;
+ /** The project's own subscription, as the webhook wrote it. */
+ sub: { status: string | null; periodEnd: string | null; cancels: boolean };
+ usage: { used: number; limit: number | null; observe_only: boolean; resets: string } | null;
+ live: boolean;
+ testMode: boolean;
+ /** Back from Stripe Checkout (?done=1). */
+ done: boolean;
+ /** Open the checkout at once (?start=1, from the genclass command). */
+ go: boolean;
+ /** The project was just added from the genclass command (?from=cli). */
+ fromCli: boolean;
+ /** Just added on the website (?new=1): after the choice, the app's setup. */
+ isNew?: boolean;
+ /** Free was chosen for it on this page before (projects.plan_chosen). */
+ chosen?: boolean;
+ now: Date;
+}
+
+/**
+ * One project's plan: what it is on, what it has used, and the paid plan large with "Try free for now (limited)"
+ * 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 {
+ const offer = PAID_PLANS[0] ?? null;
+ const a = v.access;
+ const paid = a.source === "paid";
+ const name = esc(v.project.name);
+ 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.`;
+ 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")}.` : ""}
`
+ : "";
+ const notice = v.done && !paid
+ ? '
Thanks. This page changes as soon as Stripe confirms your payment, usually within a minute.
+`;
+ const script = `
+(function(){
+ gcDropQuery(["project"]);
+ var cfg = JSON.parse(document.getElementById("cfg").textContent);
+ var b = document.querySelector("[data-plan]");
+ if (b) b.addEventListener("click", function(){
+ var err = document.getElementById("planerr"); err.textContent = "";
+ gcBusy(b, true, "Opening Stripe...");
+ gcPost("/api/billing/checkout", { plan: b.getAttribute("data-plan"), project_id: cfg.project }).then(function(j){ location.href = j.url; }, function(e){ err.textContent = gcWhy(e); gcBusy(b, false); });
+ });
+ if (b) b.focus();
+ // sent here by the genclass command to start the plan: the same click a person would make
+ if (b && cfg.go) b.click();
+ // the small way on: Free for this app, for now, once the person has said what stops them from paying (the
+ // genclass command waits for this choice or a payment)
+ var fr = document.getElementById("free");
+ var why = document.getElementById("why");
+ var go = document.getElementById("whygo");
+ if (fr && why) {
+ fr.addEventListener("click", function(e){ e.preventDefault(); if (why.showModal) why.showModal(); else why.setAttribute("open", ""); });
+ why.querySelectorAll("input[name=why]").forEach(function(r){ r.addEventListener("change", function(){ go.disabled = false; }); });
+ document.getElementById("whyteam").addEventListener("click", function(){ if (why.close) why.close(); else why.removeAttribute("open"); if (b) b.click(); });
+ document.getElementById("whyform").addEventListener("submit", function(e){
+ e.preventDefault();
+ var picked = why.querySelector("input[name=why]:checked");
+ var err = document.getElementById("whyerr"); err.textContent = "";
+ if (!picked) { err.textContent = "Pick what is stopping you."; return; }
+ gcBusy(go, true, "Saving...");
+ gcPost("/api/billing/projects/" + encodeURIComponent(cfg.project) + "/free", { reason: picked.value, note: document.getElementById("whynote").value }).then(function(){
+ location.href = "/account/apps/" + encodeURIComponent(cfg.project) + (cfg.isNew || cfg.cli ? "#connect" : "");
+ }, function(e2){ err.textContent = gcWhy(e2); gcBusy(go, false); });
+ });
+ }
+ var portal = document.getElementById("portal");
+ if (portal) portal.addEventListener("click", function(){
+ 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); });
+ });
+ if (cfg.wait) {
+ var tries = 0;
+ var timer = setInterval(function(){
+ 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(); } })
+ .catch(function(){});
+ }, 3000);
+ }
+})();`;
+ const label = v.account.email ?? (v.account.githubLogin ? `@${v.account.githubLogin}` : "Your account");
+ return studioPage(nonce, {
+ title: `${paid || a.source === "comp" || a.source === "open" ? "The plan for" : "Choose a plan for"} ${v.project.name} | GenClass`,
+ account: true,
+ org: { name: orgName(label), plan: a.plan.name, href: "/account" },
+ projects: v.apps,
+ project: null,
+ user: { label, sub: orgName(label) },
+ ...orgRail("billing"),
+ railLabel: "Organization",
+ css: DASH_CSS + ORG_CSS + BILLING_CSS + ".plan.big{max-width:560px;padding:34px 32px 30px}.plan.big h3{font-size:34px;letter-spacing:-.01em}.plan.big .price{font-size:22px;font-weight:600;margin-top:6px}.plan.big .sum{font-size:15px}.plan.big li{font-size:15px}.plan.big .act .btn{width:100%;justify-content:center;padding:15px 18px;font-size:17px}.free{margin-top:12px;font-size:11px;max-width:560px;text-align:center}.linkish{background:none;border:0;padding:0;font:inherit;font-size:11px;color:var(--ink3);text-decoration:underline;cursor:pointer}.linkish:disabled{opacity:.6;cursor:default}dialog.why{margin:auto;max-height:calc(100vh - 32px);overflow:auto;max-width:460px;width:calc(100% - 32px);border:1px solid var(--rule);border-radius:14px;background:var(--paper);color:var(--ink);padding:24px 24px 20px}dialog.why::backdrop{background:rgba(0,0,0,.62)}dialog.why h2{font-size:21px;margin:0 0 4px}dialog.why fieldset{border:0;padding:0;margin:14px 0 6px}dialog.why label{display:flex;gap:10px;align-items:center;padding:7px 0;font-size:15px;cursor:pointer}dialog.why textarea{margin-top:6px}.why-act{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}",
+ body,
+ script: CLIENT_JS + script,
+ });
+}
+
/** Back from Checkout and the plan bought is not on the account yet: that plan ("paid": any paid plan), else null. */
function awaited(v: BillingView, current: Plan): string | null {
if (!v.done) return null;
@@ -202,16 +391,14 @@ function teamSection(v: BillingView): string {
const t = v.team;
if (!t) return "";
const holds = t.seats === null || t.seats > 1;
- // the plans on sale that hold more than one person: per seat, or with no limit on people (Team, per app)
- const sold = PAID_PLANS.filter((p) => p.per_seat === true || p.limits.seats === null).map((p) => p.name);
- const perSeat = PAID_PLANS.some((p) => p.per_seat === true);
+ const sold = PAID_PLANS.filter((p) => p.per_seat === true).map((p) => p.name);
const aside = t.seats === null ? `${t.used} ${t.used === 1 ? "person" : "people"}` : `${t.used} of ${t.seats} seat${t.seats === 1 ? "" : "s"} used`;
let own: string;
if (!holds) {
own = `
${
t.access.plan.per_seat === true
? "Your plan has one seat, yours. Add seats in Manage billing to invite your team."
- : `The ${esc(t.access.plan.name)} plan is for one person.${sold.length ? ` Members come with ${esc(sold.join(" and "))}${perSeat ? ": one seat each, and they" : ": as many as you invite, and they"} open the dashboards of your apps with their own sign-in.` : ""}`
+ : `The ${esc(t.access.plan.name)} plan is for one person.${sold.length ? ` Members come with ${esc(sold.join(" and "))}: one seat each, and they open the dashboards of your apps with their own sign-in.` : ""}`
}
Members open the dashboards of your apps with their own sign-in. They cannot change an app, its forwarding or export, or see billing.${t.seats === null ? "" : " You are the first seat."}
+ own = `
Members open the dashboards of your apps with their own sign-in. They cannot change an app, its forwarding or export, or see billing. You are the first seat.
${over ? '
More members than seats. The members marked No seat cannot open your dashboards until you add seats in Manage billing or remove someone.
`
: ""
}
+${projectsSection(v)}
${teamSection(v)}
`;
@@ -322,8 +510,8 @@ ${teamSection(v)}
var body = { plan: b.getAttribute("data-plan") }, seats = document.querySelector('[data-seats="' + body.plan + '"]');
if (seats) {
var n = Number(seats.value);
- if (!(n >= 1 && n <= ${MAX_QUANTITY} && Math.floor(n) === n)) { err.textContent = (seats.getAttribute("data-unit") === "app" ? "Apps" : "Seats") + " is a whole number from 1 to ${MAX_QUANTITY}. For more, email ${esc(CONTACT)}."; return; }
- body.quantity = n;
+ if (!(n >= 1 && n <= ${MAX_SEATS} && Math.floor(n) === n)) { err.textContent = "Seats is a whole number from 1 to ${MAX_SEATS}. For a larger team, email ${esc(CONTACT)}."; return; }
+ body.seats = n;
}
gcBusy(b, true, "Opening Stripe...");
gcPost("/api/billing/checkout", body).then(function(j){ location.href = j.url; }, function(e){ err.textContent = gcWhy(e); gcBusy(b, false); });
diff --git a/telemetry-worker/src/billing.ts b/telemetry-worker/src/billing.ts
index 8b43883c..96668fe6 100644
--- a/telemetry-worker/src/billing.ts
+++ b/telemetry-worker/src/billing.ts
@@ -1,11 +1,8 @@
// Routes for the paid plans. Checked before the account routes (src/auth.ts); null for any other path.
//
-// POST /api/billing/checkout {plan: "", quantity?} browser session + Origin; {url} of Stripe Checkout (with
-// switch: true when it replaces the plan already paid);
-// `quantity` (1 to 200; `seats` is read the same) for a plan
-// sold by quantity: the apps of a per_app plan (Team; default
-// the apps the account holds), the seats of a per_seat one
-// (default 1). Checkout takes promotion codes.
+// POST /api/billing/checkout {plan: "", seats?} browser session + Origin; {url} of Stripe Checkout (with
+// switch: true when it replaces the plan already paid); `seats`
+// (1 to 200, default 1) for a plan sold per seat (Team)
// POST /api/billing/trial 410: GenClass has no trial any more (the dashboard is free)
// POST /api/billing/portal browser session + Origin; {url} of the Billing Portal
// POST /api/billing/webhook Stripe only: signature, no session (src/stripe.ts)
@@ -28,7 +25,8 @@
// EXPORT_LIMIT's.
import { accountContext, accountProjects, authenticate, csrfFail, fail, originOk, ownedProject, page, readJson, redirect, webSession, type Auth, type C } from "./auth.js";
-import { billingPage } from "./billing-pages.js";
+import { billingPage, projectPlanPage } from "./billing-pages.js";
+import { liveFixUsage } from "./usage.js";
import type { Env, HandleOptions, RateLimiterLike } from "./collector.js";
import { randomBase62, sha256Hex, type Project } from "./dashboard.js";
import { decodeCursor, exportResponse, PAGE_MAX, parseTime } from "./export.js";
@@ -39,18 +37,19 @@ import {
accountAccess,
accountBilling,
accountPlan,
- projectAccess,
canBuy,
- cleanQuantity,
+ cleanSeats,
entitled,
- MAX_QUANTITY,
+ MAX_SEATS,
+ FREE,
PAID_PLANS,
PLANS,
PLANS_FILE,
planById,
+ projectAccess,
+ accountProjectsAccess,
stripeMode,
type Entitlement,
- unitOf,
type Plan,
} from "./plans.js";
import {
@@ -60,6 +59,7 @@ import {
foreignInvoice,
HOLDING_STATUSES,
isGenClassSubscription,
+ projectOf,
managedPayments,
missingCustomer,
modeNote,
@@ -156,6 +156,14 @@ export async function routeBilling(request: Request, url: URL, path: string, env
if (a instanceof Response) return a;
const b = await accountBilling(c.db, a.account.id, env);
const access = await accountAccess(c.db, a.account.id, env, c.now);
+ // one project's plan (?project=): its page waits for its payment with this
+ const pid = url.searchParams.get("project");
+ if (pid) {
+ const p = await ownedProject(c, a.account.id, pid);
+ if (!p) return fail(404, "not found");
+ const pa = await projectAccess(c.db, p, env, c.now);
+ return json(200, { project: { id: p.id, plan: pa.plan.id, source: pa.source, locked: pa.locked ?? null } });
+ }
return json(200, {
plan: planById(b.plan).id,
subscription_status: b.subscription_status,
@@ -166,12 +174,43 @@ export async function routeBilling(request: Request, url: URL, path: string, env
access: access.source,
// the people the account holds now, the owner included (null: no limit)
seats: access.seats,
- // the apps the account may hold now (a per_app plan: the apps paid for; null: no limit)
- apps: access.apps,
});
}
case "/account/billing": {
if (!get) return method();
+ // one project's plan page (?project=): what the genclass command opens when it adds a project
+ const pid = url.searchParams.get("project");
+ if (pid) {
+ const start = url.searchParams.get("start") === "1";
+ const fromCli = url.searchParams.get("from") === "cli";
+ const isNew = url.searchParams.get("new") === "1";
+ const a = await webSession(c);
+ if (!a) return redirect(`/login?next=${encodeURIComponent(`/account/billing?project=${encodeURIComponent(pid)}${start ? "&start=1" : ""}${fromCli ? "&from=cli" : ""}${isNew ? "&new=1" : ""}`)}`);
+ const p = await ownedProject(c, a.account.id, pid);
+ if (!p || !p.id) return redirect("/account/billing#projects");
+ const access = await projectAccess(c.db, p, env, c.now);
+ const row = await c.db.prepare("SELECT subscription_status, current_period_end, cancel_at_period_end, plan_chosen FROM projects WHERE id = ?").bind(p.id).first<{ subscription_status: string | null; current_period_end: string | null; cancel_at_period_end: number | null; plan_chosen: string | null }>();
+ const apps = (await accountProjects(c, a.account.id)).map((x) => ({ id: x.id!, name: x.name }));
+ const usage = await liveFixUsage(c.db, p.token, access.plan, c.now).catch(() => null);
+ return page(200, (n) =>
+ projectPlanPage(n, {
+ account: { id: a.account.id, email: a.account.email, githubLogin: a.account.github_login },
+ apps,
+ project: { id: p.id!, name: p.name },
+ access,
+ sub: { status: row?.subscription_status ?? null, periodEnd: row?.current_period_end ?? null, cancels: !!row?.cancel_at_period_end },
+ usage,
+ live: canBuy(env, a.account),
+ testMode: stripeMode(env) === "test",
+ done: url.searchParams.get("done") === "1",
+ go: start,
+ fromCli,
+ isNew,
+ chosen: !!row?.plan_chosen,
+ now: c.now,
+ }),
+ );
+ }
const picked = pickedPlan(url.searchParams.get("plan"));
// ?start=1 with a plan: the person chose it a moment ago (in the genclass command's plan step), so the page
// opens that plan's checkout itself instead of waiting for one more click
@@ -179,19 +218,27 @@ export async function routeBilling(request: Request, url: URL, path: string, env
const a = await webSession(c);
// the plan picked on the pricing page survives the sign-in
if (!a) return redirect(`/login?next=${encodeURIComponent(`/account/billing${picked ? `?plan=${picked.id}${go ? "&start=1" : ""}` : ""}`)}`);
+ // a plan sold per project, picked on the pricing page or started from an older genclass command: the plan page
+ // of the account's first app still on Free, where it is bought for that app
+ if (picked?.per_project === true) {
+ const open = (await accountProjectsAccess(c.db, a.account.id, env, c.now)).find((x) => x.row.id && x.access.plan.id === FREE.id && x.access.source === null && !x.access.locked)
+ ?? (await accountProjectsAccess(c.db, a.account.id, env, c.now)).find((x) => x.row.id && x.access.locked);
+ if (open) return redirect(`/account/billing?project=${encodeURIComponent(open.row.id!)}${go ? "&from=cli" : ""}`);
+ }
const access = await accountAccess(c.db, a.account.id, env, c.now);
- const all = await accountProjects(c, a.account.id);
- const apps = all.map((p) => ({ id: p.id!, name: p.name }));
+ const apps = (await accountProjects(c, a.account.id)).map((p) => ({ id: p.id!, name: p.name }));
const b = await accountBilling(c.db, a.account.id, env);
const team = await teamOf(c.db, env, a.account.id, c.now);
const memberships = await membershipsOf(c.db, env, a.account.id, c.now);
+ const names = new Map((await accountProjects(c, a.account.id)).filter((x) => x.account_id === a.account.id).map((x) => [x.token, x.name]));
+ const projects = (await accountProjectsAccess(c.db, a.account.id, env, c.now)).filter((x) => names.has(x.row.token)).map((x) => ({ id: x.row.id!, name: names.get(x.row.token)!, access: x.access }));
return page(200, (n) =>
billingPage(n, {
+ projects,
team,
memberships,
account: { id: a.account.id, email: a.account.email, githubLogin: a.account.github_login },
apps,
- ownApps: all.filter((p) => p.account_id === a.account.id).length,
billing: b,
access,
live: canBuy(env, a.account),
@@ -206,6 +253,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();
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);
@@ -214,6 +263,49 @@ export async function routeBilling(request: Request, url: URL, path: string, env
// --------------------------------------------------------------------------------------------- Checkout, portal
+/** 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"],
+ ["trying", "I'm only trying GenClass out"],
+ ["approval", "I need to ask my team or my boss"],
+ ["feature", "It's missing something I need"],
+ ["unsure", "I'm not sure it works for my app yet"],
+ ["other", "Something else"],
+];
+const NOTE_MAX = 500;
+
+/**
+ * POST /api/billing/projects//free { reason, note? }: Free chosen for one app, on its plan page, with what is
+ * stopping the person from paying (the popup behind "Try for free (limited)"; the owner's call, 2026-11-12: nobody
+ * goes on with Free without saying why). The reason is one of FREE_REASONS; the note is optional, at most 500
+ * characters. It keeps the answer (plan_feedback), writes when (projects.plan_chosen), which the genclass command's
+ * plan step and the app's page wait for, and tells the owner (NOTIFY_WEBHOOK). The app is on Free either way. An app
+ * past the ones Free covers has only the paid plan (409).
+ */
+async function chooseFree(c: C, f: Fetch, id: string): Promise {
+ const a = await browserWrite(c, "a plan is chosen");
+ if (a instanceof Response) return a;
+ const body = await readJson(c, 4096);
+ if (body instanceof Response) return body;
+ const reason = FREE_REASONS.find(([v]) => v === body.reason);
+ if (!reason) return fail(400, "choose what is stopping you from paying");
+ const note = typeof body.note === "string" ? body.note.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, NOTE_MAX) : "";
+ 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`);
+ 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),
+ c.db.prepare("UPDATE projects SET plan_chosen = ? WHERE id = ? AND account_id = ?").bind(at, p.id, a.account.id),
+ ]);
+ await notifyOwner(
+ { env: c.env, fetch: f, now: c.now },
+ { kind: "free_chosen", account: a.account.id, project: p.id, reason: reason[0], text: `Free chosen for "${p.name}" (${a.account.email ?? a.account.github_login ?? a.account.id}): ${reason[1]}${note ? `. "${note}"` : ""}` },
+ ).catch(() => false);
+ return json(200, { ok: true, plan: access.plan.id });
+}
+
/**
* GenClass opens no Stripe page for, and starts no Checkout on, a stored customer that is not provably its own or that
* holds anything else (src/stripe.ts checkCustomer, sharedCustomer, foreignInvoice): a defence kept from when the
@@ -252,20 +344,17 @@ async function checkout(c: C, f: Fetch): Promise {
if (!PAID_PLANS.length) return fail(400, `no plan is sold on genclass.dev: Free costs nothing, and Enterprise is agreed by email with ${PLANS_FILE.contact}`);
return fail(400, `choose a plan: ${PAID_PLANS.map((p) => p.id).join(" or ")}`);
}
- // a plan sold by quantity: how many apps (per_app; the apps the account holds when the page sends none) or people
- // (per_seat, the owner included; one when the page sends none)
- let quantity = 1;
- const unit = unitOf(plan);
- if (unit) {
- const asked = body.quantity !== undefined ? body.quantity : body.seats;
- if (asked !== undefined) {
- const n = cleanQuantity(asked);
- if (n === null) return fail(400, `the number of ${unit}s is a whole number from 1 to ${MAX_QUANTITY}; for more email ${PLANS_FILE.contact}`);
- quantity = n;
- } else if (unit === "app") {
- // the account's own apps, not the apps of teams it is a member of
- quantity = Math.min(MAX_QUANTITY, Math.max(1, (await accountProjects(c, a.account.id)).filter((p) => p.account_id === a.account.id).length));
- }
+ // a plan sold per project (billing/plans.json version 4) is bought for one of the account's apps
+ if (plan.per_project === true) {
+ 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
+ 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 (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
@@ -302,11 +391,11 @@ async function checkout(c: C, f: Fetch): Promise {
if (await foreignInvoice(c.env, f, customer.id, subs)) return refuseShared(c, f, account.id, customer.id, SHARED_INVOICE);
// once the new subscription's first payment clears, the webhook refunds `held`'s unused time to its card and
// cancels it; until then it stays
- const url = await checkoutSession(c.env, f, { account, customer: customer.id, plan, origin: c.cfg.origin, now: c.now, switchFrom: held.id, quantity });
+ const url = await checkoutSession(c.env, f, { account, customer: customer.id, plan, origin: c.cfg.origin, now: c.now, switchFrom: held.id, seats });
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, quantity });
+ 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 });
} catch (e) {
return stripeFail(e);
@@ -354,6 +443,41 @@ async function portal(c: C, f: Fetch): Promise {
}
}
+/**
+ * Checkout for one project: the project must be the account's and not already on that plan; the Stripe customer is
+ * the account's own (the same checks as the account's checkout: GenClass's, holding nothing else). One subscription
+ * of quantity 1 per project, its metadata naming the project (src/stripe.ts checkoutSession, applyProjectSubscription);
+ * the account's other subscriptions are never touched.
+ */
+async function checkoutProject(c: C, f: Fetch, a: Auth, plan: Plan, projectId: string): Promise {
+ const p = await ownedProject(c, a.account.id, projectId);
+ if (!p || !p.id) return fail(404, "not found");
+ const access = await projectAccess(c.db, p, c.env, c.now);
+ if (access.source === "paid" && access.plan.id === plan.id) return fail(409, `${p.name} is already on the ${plan.name} plan`);
+ const row = await c.db.prepare("SELECT subscription_status FROM projects WHERE id = ?").bind(p.id).first<{ subscription_status: string | null }>();
+ if (row?.subscription_status === "past_due" || row?.subscription_status === "unpaid") {
+ return fail(409, `the last payment for ${p.name} did not go through; update your card in Manage billing to pay it, or email ${PLANS_FILE.contact}`);
+ }
+ if (await overBudget(c.env.AUTH_LIMIT, "checkout", a.account.id)) return tooMany();
+ const account = { id: a.account.id, email: a.account.email, ...(await accountBilling(c.db, a.account.id)) };
+ try {
+ let customer = await ensureCustomer(c.env, f, c.db, account);
+ if (!customer.fresh) {
+ const own = await checkCustomer(c.env, f, account.id, customer.id);
+ 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);
+ 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);
+ // a payment for this project that the webhook has not written yet: never a second subscription
+ const subs = own.state === "ok" ? own.subs : [];
+ if (subs.some((s) => projectOf(s) === p.id && HOLDING_STATUSES.has(s.status))) return fail(409, `a plan for ${p.name} is already going through; reload this page in a minute`);
+ }
+ const url = await checkoutSession(c.env, f, { account, customer: customer.id, plan, origin: c.cfg.origin, now: c.now, fresh: customer.fresh, project: { id: p.id } });
+ return json(200, { url });
+ } catch (e) {
+ return stripeFail(e);
+ }
+}
+
// --------------------------------------------------------------------------------------------- forwarding settings
async function integrations(c: C, f: Fetch, projectId: string, kindRaw: string | null, test: boolean): Promise {
@@ -375,7 +499,7 @@ export async function integrationsFor(c: C, f: Fetch, accountId: string, project
const a = { account: { id: accountId } };
const p = await ownedProject(c, a.account.id, projectId);
if (!p || !p.id) return fail(404, "not found");
- // a grant gives its plan's forwarding too, and an app past the apps a per_app plan pays for has Free's (projectAccess)
+ // the project's own plan (a grant gives its plan's forwarding too: src/plans.ts projectAccess)
const plan = (await projectAccess(c.db, p, c.env, c.now)).plan;
if (kindRaw === null) {
diff --git a/telemetry-worker/src/cli.ts b/telemetry-worker/src/cli.ts
index 6b828a9d..8dcfea16 100644
--- a/telemetry-worker/src/cli.ts
+++ b/telemetry-worker/src/cli.ts
@@ -50,7 +50,8 @@ import { isKind, KINDS } from "./forward.js";
import { githubApp } from "./github/config.js";
import { repoStatusForAccount } from "./github/http.js";
import { json } from "./http.js";
-import { accountAccess, accountBilling, canBuy, entitled, firstPlanWith, FREE, hasAnalytics, hasDashboard, PAID_PLANS, pickRange, priceText, projectAccess, viewRanges, type Access } from "./plans.js";
+import { accountAccess, accountBilling, accountProjectsAccess, canBuy, entitled, firstPlanWith, FREE, hasAnalytics, hasDashboard, PAID_PLANS, pickRange, priceText, projectAccess, viewRanges, type Access } from "./plans.js";
+import { liveFixUsage } from "./usage.js";
import { askModel, byWeight, claimRun, hasModel, MAX_SUGGEST_BYTES, readDigest, suggestionLimits, usedToday, type PlanItem } from "./suggest.js";
import { createInvite, isTeamError, membershipsOf, teamOf } from "./team.js";
@@ -67,10 +68,26 @@ async function overview(c: C): Promise {
account: accountJson(a.account),
plan: { id: access.plan.id, name: access.plan.name },
suggestions: { per_day: lim.per_day, shown: lim.shown, used_today: await usedToday(c.db, a.account.id, c.now), model: hasModel(c.env) },
- projects: (await accountProjects(c, a.account.id)).map((p) => projectJson(c, p)),
+ projects: await withPlans(c, a.account.id),
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: offerFor(c, a.account, access),
+ offer: offerFor(c, a.account, access, await accountProjectsAccess(c.db, a.account.id, c.env, c.now)),
+ });
+}
+
+/**
+ * The account's apps (its own and its teams'), each with its plan and the page where it is chosen (plans are per
+ * project since billing/plans.json version 4): `plan` { id, name, source }, `needs_plan` for one past the projects
+ * Free covers, `plan_url`. An app of a team the account is in carries no plan: its owner chooses it.
+ */
+async function withPlans(c: C, accountId: string): Promise[]> {
+ const access = new Map((await accountProjectsAccess(c.db, accountId, c.env, c.now)).map((x) => [x.row.token, x.access]));
+ const chosen = new Set(((await c.db.prepare("SELECT token FROM projects WHERE account_id = ? AND plan_chosen IS NOT NULL").bind(accountId).all<{ token: string }>()).results ?? []).map((r) => r.token));
+ return (await accountProjects(c, accountId)).map((p) => {
+ 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 ?? "")}` });
+ return j;
});
}
@@ -80,16 +97,21 @@ async function overview(c: C): Promise {
* checkout, and Free's limits in numbers. Null for an account that has a plan or a grant, on a server that sells
* nothing, and for an account that cannot buy here (a test key and an account not on its list).
*/
-function offerFor(c: C, account: { id: string; email: string | null }, access: Access): Record | null {
+function offerFor(c: C, account: { id: string; email: string | null }, access: Access, projects?: { row: { id?: string | null }; access: Access }[]): Record | null {
const plan = PAID_PLANS[0];
- if (!plan || access.plan.id !== FREE.id || access.source !== null || !canBuy(c.env, account)) return null;
+ if (!plan || !canBuy(c.env, account)) return null;
+ // a plan sold per project is offered while one of the account's apps is on Free, and only once it has one (the
+ // 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;
return {
plan: { id: plan.id, name: plan.name },
price: priceText(plan),
summary: plan.summary,
features: plan.features.filter((f) => f.available && !/^Everything in /i.test(f.text)).map((f) => f.text).slice(0, 12),
- checkout_url: `${c.cfg.origin}/account/billing?plan=${plan.id}&start=1`,
- free: { name: FREE.name, apps: FREE.limits.apps, history_days: FREE.limits.history_days, suggestions_shown: suggestionLimits(FREE).shown },
+ checkout_url: one ? `${c.cfg.origin}/account/billing?project=${encodeURIComponent(one)}&from=cli` : `${c.cfg.origin}/account/billing?plan=${plan.id}&start=1`,
+ 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 },
};
}
@@ -120,11 +142,12 @@ async function app(c: C): Promise {
if (!p) return fail(404, "not found");
// the same plan rules as the dashboard page's own request (src/auth.ts, /api/account/projects//stats)
const access = await projectAccess(c.db, p, c.env, c.now);
- if (!hasDashboard(access)) return lockedJson(c);
+ if (!hasDashboard(access)) return lockedJson(c, access, p.id);
const range = pickRange(c.url.searchParams.get("range"), viewRanges(access.plan));
const s = await stats(c.db, p, range, c.now);
const view = hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: `${c.cfg.origin}/account/billing` });
- return json(200, { ...view, recent: view.recent.slice(0, APP_RECENT), dashboard_url: projectJson(c, p).dashboard_url });
+ const usage = await liveFixUsage(c.db, p.token, access.plan, c.now).catch(() => null);
+ return json(200, { ...view, recent: view.recent.slice(0, APP_RECENT), dashboard_url: projectJson(c, p).dashboard_url, usage, plan: { id: access.plan.id, name: access.plan.name, source: access.source }, plan_url: `${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}` });
}
async function suggest(c: C): Promise {
@@ -202,7 +225,7 @@ async function account(c: C): Promise {
plan: { id: access.plan.id, name: access.plan.name, price: access.plan.id === FREE.id ? null : priceText(access.plan), summary: access.plan.summary },
// why the account has its plan now: "paid", "comp" (GenClass's own grant), "open" (this server's PLAN_GATES), or null on Free
access: access.source,
- limits: { apps: access.apps, history_days: access.plan.limits.history_days, seats: access.seats, suggestions_per_day: lim.per_day, suggestions_shown: lim.shown },
+ limits: { apps: access.plan.limits.apps, history_days: access.plan.limits.history_days, seats: access.seats, suggestions_per_day: lim.per_day, suggestions_shown: lim.shown },
used: { apps: apps.filter((p) => p.account_id === a.account.id).length, suggestions_today: await usedToday(c.db, a.account.id, c.now), seats: t.used },
includes: { analytics: entitled(access.plan, "analytics"), forwarding: entitled(access.plan, "forwarding"), export: entitled(access.plan, "export"), private_repositories: entitled(access.plan, "github_private") },
subscription: { status: b.subscription_status, current_period_end: b.current_period_end, cancel_at_period_end: !!b.cancel_at_period_end },
@@ -214,7 +237,7 @@ async function account(c: C): Promise {
memberships: memberships.map((x) => ({ email: x.email, github_login: x.githubLogin, joined: x.joined, active: x.active })),
},
billing_url: `${c.cfg.origin}/account/billing`,
- offer: offerFor(c, a.account, access),
+ offer: offerFor(c, a.account, access, await accountProjectsAccess(c.db, a.account.id, c.env, c.now)),
});
}
@@ -255,8 +278,8 @@ async function funnels(c: C): Promise {
const p = await ownedProject(c, a.account.id, c.url.searchParams.get("id") ?? "");
if (!p) return fail(404, "not found");
// the same plan rules as the dashboard page's funnel editor (src/auth.ts)
- const access = await accountAccess(c.db, a.account.id, c.env, c.now);
- if (!hasDashboard(access)) return lockedJson(c);
+ const access = await projectAccess(c.db, p, c.env, c.now);
+ if (!hasDashboard(access)) return lockedJson(c, access, p.id);
if (!hasAnalytics(access)) return upgradeJson(c, "funnels are part of visitor analytics", "analytics");
return putFunnels(c.req, c.db, p.token);
}
@@ -279,7 +302,7 @@ async function ask(c: C, f: Fetch): Promise {
if (!p) return fail(404, "not found");
// the numbers the model gets are the ones this account's dashboard shows for the app: the same plan rules
const access = await projectAccess(c.db, p, c.env, c.now);
- if (!hasDashboard(access)) return lockedJson(c);
+ if (!hasDashboard(access)) return lockedJson(c, access, p.id);
const mine = await accountAccess(c.db, a.account.id, c.env, c.now);
const lim = suggestionLimits(mine.plan);
const left = async (): Promise => Math.max(0, lim.per_day - (await usedToday(c.db, a.account.id, c.now)));
diff --git a/telemetry-worker/src/collector.ts b/telemetry-worker/src/collector.ts
index 590ef3d0..901595b3 100644
--- a/telemetry-worker/src/collector.ts
+++ b/telemetry-worker/src/collector.ts
@@ -25,6 +25,7 @@
// its webhook, its pages and the link between a repository and an app. Off, and every route above unchanged, until
// the app's secrets are set (src/github/config.ts).
+import { OBSERVE_HEADER, OBSERVE_REASON, observeOnly } from "./usage.js";
import { authConfig, lockedError, routeAuth, runtimeHasTokens } from "./auth.js";
import {
AGENT_MAX_BODY,
@@ -141,12 +142,14 @@ const CORS: Record = {
"access-control-allow-methods": "POST, GET, OPTIONS",
"access-control-allow-headers": "content-type",
"access-control-max-age": "86400",
+ // the runtime reads this one header of ingest's reply (src/usage.ts): observe only, past the plan's live-fix limit
+ "access-control-expose-headers": OBSERVE_HEADER,
};
-export function reply(status: number, body: unknown): Response {
+export function reply(status: number, body: unknown, extra?: Record): Response {
return new Response(body === null ? null : JSON.stringify(body), {
status,
- headers: { ...CORS, "content-type": "application/json", "cache-control": "no-store" },
+ headers: { ...CORS, "content-type": "application/json", "cache-control": "no-store", ...(extra ?? {}) },
});
}
@@ -321,8 +324,11 @@ async function ingest(request: Request, env: Env, opts: HandleOptions): Promise<
return reply(503, { ok: false, error: "storage unavailable" });
}
- // Dashboard aggregation for a known token. Never affects the response (failures are swallowed).
+ // Past its plan's live fixes for the month, the runtime is told to observe only (src/usage.ts): read before this
+ // batch is counted (cached a minute), so the batch itself is stored and counted the same either way.
const db = env.DB;
+ const observe = batch.token && db ? await observeOnly(db, env, batch.token, now).catch(() => false) : false;
+ // Dashboard aggregation for a known token. Never affects the response (failures are swallowed).
if (batch.token && db) {
const token = batch.token;
const task = (async () => {
@@ -335,7 +341,7 @@ async function ingest(request: Request, env: Env, opts: HandleOptions): Promise<
if (opts.waitUntil) opts.waitUntil(task);
else await task;
}
- return reply(202, { ok: true, accepted: batch.events.length });
+ return reply(202, { ok: true, accepted: batch.events.length }, observe ? { [OBSERVE_HEADER]: OBSERVE_REASON } : undefined);
}
// ------------------------------------------------------------------------------------------ tokens and dashboards
diff --git a/telemetry-worker/src/github/http.ts b/telemetry-worker/src/github/http.ts
index 07cd6196..420f056d 100644
--- a/telemetry-worker/src/github/http.ts
+++ b/telemetry-worker/src/github/http.ts
@@ -316,7 +316,7 @@ async function connectCallback(c: C, cfg: GithubAppConfig, f: Fetch): Promise Promise;
@@ -40,13 +40,20 @@ export async function ownerOf(db: D1Like, token: string, nowMs: number, mode: St
const cols = ACCESS_COLUMNS.split(", ").map((k) => `a.${k}`).join(", ");
const row = await db
.prepare(
- `SELECT p.id, p.name, p.account_id, ${cols}, (SELECT COUNT(*) FROM integrations i WHERE i.project_id = p.id AND i.enabled = 1) AS n ` +
+ `SELECT p.id, p.name, p.account_id, p.created, p.plan AS p_plan, p.stripe_mode AS p_mode, p.comp_until AS p_comp, p.comp_plan AS p_comp_plan, ${cols}, ` +
+ "(SELECT COUNT(*) FROM integrations i WHERE i.project_id = p.id AND i.enabled = 1) AS n " +
"FROM projects p LEFT JOIN accounts a ON a.id = p.account_id WHERE p.token = ?",
)
.bind(token)
- .first();
- // an app past the apps a per_app plan pays for has Free's export and forwarding (src/plans.ts withinPaidApps)
- const plan = row?.account_id ? (await withinPaidApps(db, row.id, accessOf(row, mode, new Date(nowMs), open))).plan : FREE;
+ .first();
+ 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;
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 0a04c0c1..6e430d23 100644
--- a/telemetry-worker/src/plans.ts
+++ b/telemetry-worker/src/plans.ts
@@ -13,14 +13,9 @@
// GenClass's (ownedPrice, plans.json retired_plans). Every plan includes the dashboard; the locked paths
// (hasDashboard false) are kept for a plan file that leaves it out, and the tests run them on a fixture
// (test/fixtures/plans-two-paid.json). There is no trial.
-// - Quantity. A plan sold by quantity (soldByQuantity) has one Stripe subscription item whose quantity src/stripe.ts
-// writes to accounts.seats (the column keeps its first name). Team is per_app since 2026-11-12: the quantity is how
-// many apps the plan covers (appsOf): no more apps are created past it, and when the account holds more apps than
-// it pays for (it lowered the quantity in Manage billing), only its first apps by creation have the plan's
-// entitlements, the others Free's (projectAccess). A per_seat plan would be paid per person instead: the quantity
+// - Seats. A per_seat plan is paid per person: the subscription's quantity (accounts.seats, written by src/stripe.ts)
// is how many people the account holds, its owner included. A plan that is not per_seat holds limits.seats people
-// (Free: 1; null: no limit, Team's), and a plan GenClass grants has no seat or app count, so no limit. seatsOf and
-// appsOf say it.
+// (Free: 1; null: no limit), and a plan GenClass grants has no seat count, so no limit. seatsOf says it.
// - PLAN_GATES = "open" (a self-hosted collector, wrangler.selfhost.toml): every account has the grant plan, so
// nothing is locked on a server that sells nothing (planGatesOpen, source "open").
// - A plan comes from accounts.plan, which src/stripe.ts derives from the Stripe subscription. Entitlements follow the
@@ -72,10 +67,15 @@ export interface Plan {
managed_payments: boolean;
/** Sold per person: the monthly price is for one seat, and the subscription's quantity is the seats (seatsOf). */
per_seat?: boolean;
- /** Sold per app: the monthly price is for one app, and the subscription's quantity is the apps it covers (appsOf). */
- per_app?: boolean;
- /** `seats`: the people a plan that is not per_seat holds, the owner included (null or left out: no limit). */
- limits: { apps: number | null; history_days: number; seats?: number | null };
+ /** Sold per project (version 4): one subscription of quantity 1 for each app, its metadata naming the app. */
+ per_project?: boolean;
+ /**
+ * `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).
+ * `live_fixes_per_month`: what the runtime may fix on visitors' screens a month for one project (interventions and
+ * visual fixes); past it the runtime is told to observe only until the month ends (null or left out: no limit).
+ */
+ limits: { apps: number | null; history_days: number; seats?: number | null; live_fixes_per_month?: number | null };
/**
* `genclass suggest` (src/suggest.ts): `per_day`, model runs an account has a day; `shown`, how many items of the
* plan it is shown (null: all). Left out: 3 and 3.
@@ -189,26 +189,20 @@ export function canBuy(env: Env, account: { id: string; email: string | null }):
return list.some((x) => x === account.id || (!!email && x.toLowerCase() === email));
}
-/** "$29 / month", "$15 / developer / month" (per_seat), "$15 / app / month" (per_app), "$0", or "By agreement". */
+/** "$29 / month", "$15 / developer / month" (a per_seat plan), "$0", or "By agreement" for a plan agreed by email. */
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_seat === true ? " / developer" : plan.per_app === true ? " / app" : ""} / month`;
+ return `$${c % 100 ? (c / 100).toFixed(2) : String(c / 100)}${plan.per_project === true ? " / project" : plan.per_seat === true ? " / developer" : ""} / month`;
}
-/** Sold by quantity: one subscription item whose quantity is the seats (per_seat) or the apps (per_app). */
-export const soldByQuantity = (plan: Plan): boolean => plan.per_seat === true || plan.per_app === true;
-
-/** What one unit of a plan sold by quantity is, for the pages and messages: "seat" or "app" (else null). */
-export const unitOf = (plan: Plan): "seat" | "app" | null => (plan.per_seat === true ? "seat" : plan.per_app === true ? "app" : null);
+/** The most seats one Checkout sells and one account holds on a per_seat plan; a larger team is agreed by email. */
+export const MAX_SEATS = 200;
-/** The most seats or apps one Checkout sells (the apps match auth.ts MAX_APPS_PER_ACCOUNT); more is agreed by email. */
-export const MAX_QUANTITY = 200;
-
-/** A quantity as asked for (Checkout) or as Stripe reports it (a subscription item's quantity): 1 to MAX_QUANTITY. */
-export function cleanQuantity(v: unknown): number | null {
- return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= MAX_QUANTITY ? v : null;
+/** A seat count as asked for (Checkout) or as Stripe reports it (a subscription item's quantity): 1 to MAX_SEATS. */
+export function cleanSeats(v: unknown): number | null {
+ return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= MAX_SEATS ? v : null;
}
/** PLAN_GATES = "open": this server gives every account the grant plan (a self-hosted collector). */
@@ -278,8 +272,8 @@ export interface Access {
source: AccessSource | null;
/** How many people the account holds now, its owner included (seatsOf); null: no limit. */
seats: number | null;
- /** How many apps the account may hold now (appsOf): Free's 3, the apps paid for on a per_app plan; null: no limit. */
- apps: 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";
}
/** The columns accountAccess reads (accounts, migrations 0004, 0006 and 0014). */
@@ -289,14 +283,14 @@ export interface AccessRow {
comp_until: string | null;
/** The plan GenClass's grant gives (migration 0014); empty or unknown: plans.json grant_plan. */
comp_plan?: string | null;
- /** The quantity paid for on a plan sold by quantity, seats or apps (migration 0014, src/stripe.ts). */
+ /** The paid seats of a per_seat plan: the subscription's quantity (migration 0014, src/stripe.ts). */
seats?: number | null;
}
export const ACCESS_COLUMNS = "plan, stripe_mode, comp_until, comp_plan, seats";
-export const NO_ACCESS: Access = { plan: FREE, source: null, seats: FREE.limits.seats ?? null, apps: FREE.limits.apps };
+export const NO_ACCESS: Access = { plan: FREE, source: null, seats: FREE.limits.seats ?? null };
/** What every account has on a server with PLAN_GATES open. */
-export const OPEN_ACCESS: Access = { plan: GRANT_PLAN, source: "open", seats: null, apps: null };
+export const OPEN_ACCESS: Access = { plan: GRANT_PLAN, source: "open", seats: null };
/**
* How many people an account on `plan` holds, its owner included (null: no limit). A paid per_seat plan: the seats
@@ -304,19 +298,10 @@ export const OPEN_ACCESS: Access = { plan: GRANT_PLAN, source: "open", seats: nu
* no seat count, so no limit).
*/
export function seatsOf(plan: Plan, source: AccessSource | null, paidSeats: number | null | undefined): number | null {
- if (plan.per_seat === true) return source === "paid" ? Math.max(1, cleanQuantity(paidSeats) ?? 1) : null;
+ if (plan.per_seat === true) return source === "paid" ? Math.max(1, cleanSeats(paidSeats) ?? 1) : null;
return plan.limits.seats ?? null;
}
-/**
- * How many apps an account on `plan` may hold (null: no plan limit; the abuse cap still applies). A paid per_app plan:
- * the apps paid for, at least 1. A grant of a per_app plan: no limit. Any other plan: its limits.apps.
- */
-export function appsOf(plan: Plan, source: AccessSource | null, paid: number | null | undefined): number | null {
- if (plan.per_app === true) return source === "paid" ? Math.max(1, cleanQuantity(paid) ?? 1) : null;
- return plan.limits.apps;
-}
-
/** The plan a grant gives: the one accounts.comp_plan names when it is a plan above Free, else plans.json grant_plan. */
export function grantPlan(compPlan: string | null | undefined): Plan {
const p = compPlan ? PLANS.find((x) => x.id === compPlan) : undefined;
@@ -343,16 +328,56 @@ export function accessOf(row: AccessRow | null | undefined, mode: StripeMode | n
const paid = modeCounts(row.stripe_mode, mode) ? planById(row.plan) : FREE;
const comp = future(row.comp_until, now);
const grant = grantPlan(row.comp_plan);
- if (paid.id !== "free" && (!comp || rank(paid) >= rank(grant))) return { plan: paid, source: "paid", seats: seatsOf(paid, "paid", row.seats), apps: appsOf(paid, "paid", row.seats) };
- if (comp) return { plan: grant, source: "comp", seats: seatsOf(grant, "comp", null), apps: appsOf(grant, "comp", null) };
+ if (paid.id !== "free" && (!comp || rank(paid) >= rank(grant))) return { plan: paid, source: "paid", seats: seatsOf(paid, "paid", row.seats) };
+ if (comp) return { plan: grant, source: "comp", seats: seatsOf(grant, "comp", null) };
return NO_ACCESS;
}
-/** The account's access at `now` under the current Stripe key (accessOf). */
+/** The better of two accesses: the plan listed later in plans.json, a paid one before a grant of the same plan. */
+export function better(a: Access, b: Access): Access {
+ if (rank(b.plan) !== rank(a.plan)) return rank(b.plan) > rank(a.plan) ? b : a;
+ return a.source === "paid" || b.source !== "paid" ? a : b;
+}
+
+/** The columns a project's own plan is read from (migration 0016). */
+export const PROJECT_PLAN_COLUMNS = "id, token, created, plan, stripe_mode, comp_until, comp_plan";
+export interface ProjectPlanRow {
+ id: string | null;
+ token: string;
+ created: string;
+ plan: string | null;
+ stripe_mode: string | null;
+ comp_until: string | null;
+ comp_plan: string | null;
+}
+
+/**
+ * What an account gives every one of its apps: a subscription of the whole account from before plans were per
+ * project, and GenClass's grant of a plan that is not sold per project (Enterprise, agreed by email). A grant of a
+ * plan sold per project (Team) covers only the apps it was copied to (migration 0016: the owner's choice, "every
+ * project they have").
+ */
+export function accountWide(row: AccessRow | null | undefined, mode: StripeMode | null, now: Date): Access {
+ if (!row) return NO_ACCESS;
+ return accessOf(grantPlan(row.comp_plan).per_project === true ? { ...row, comp_until: null } : row, mode, now);
+}
+
+/** A project's own access: its paid plan (as the current Stripe key sees it) or its grant (accessOf), else Free. */
+export const projectOwnAccess = (r: ProjectPlanRow, mode: StripeMode | null, now: Date): Access =>
+ accessOf({ plan: r.plan, stripe_mode: r.stripe_mode, comp_until: r.comp_until, comp_plan: r.comp_plan, seats: null }, mode, now);
+
+/**
+ * What an account may use for itself (suggestions a day, its team, export keys) at `now`: the best of its own paid
+ * plan or grant (accessOf; from before plans were per project) and every one of its projects' (projectOwnAccess).
+ */
export async function accountAccess(db: D1Like, accountId: string, env: Env, now: Date): Promise {
if (planGatesOpen(env)) return OPEN_ACCESS;
+ const mode = stripeMode(env);
const row = await db.prepare(`SELECT ${ACCESS_COLUMNS} FROM accounts WHERE id = ?`).bind(accountId).first();
- return accessOf(row, stripeMode(env), now);
+ let best = accessOf(row, mode, now);
+ const r = await db.prepare(`SELECT ${PROJECT_PLAN_COLUMNS} FROM projects WHERE account_id = ?`).bind(accountId).all();
+ for (const p of r.results ?? []) best = better(best, projectOwnAccess(p, mode, now));
+ return best;
}
/** The account's plan for its entitlements at `now`: the paid plan or the grant's, else Free. */
@@ -360,36 +385,52 @@ export async function accountPlan(db: D1Like, accountId: string, env: Env, now:
return (await accountAccess(db, accountId, env, now)).plan;
}
-/** This access opens dashboards (every plan in billing/plans.json today). */
-export const hasDashboard = (a: Access): boolean => entitled(a.plan, "dashboard");
+/** 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;
/** 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");
+/** The access behind an app: its account's, or Free's for an app known only by its private link. */
/**
- * The access behind an app: its account's, or Free's for an app known only by its private link. On a paid per_app plan
- * only the account's first `apps` apps by creation (then id) have it; an app past them (the account lowered the
- * quantity in Manage billing) has Free's, like an app with no account.
+ * 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 projectAccess(db: D1Like, project: { id?: string | null; account_id?: string | null }, env: Env, now: Date): Promise {
- if (planGatesOpen(env)) return OPEN_ACCESS;
- if (!project.account_id) return NO_ACCESS;
- return withinPaidApps(db, project.id ?? null, await accountAccess(db, project.account_id, env, now));
+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 };
+ });
}
-/**
- * `access` for the app `projectId` of the account it belongs to: unchanged, unless the account pays per app (a paid
- * per_app plan) and the app is not among its first `access.apps` apps by creation, then id; such an app has Free's.
- */
-export async function withinPaidApps(db: D1Like, projectId: string | null, access: Access): Promise {
- if (access.source !== "paid" || access.plan.per_app !== true || access.apps === null || !projectId) return access;
- const before = await db
- .prepare(
- "SELECT COUNT(*) AS n FROM projects q JOIN projects p ON p.id = ? WHERE q.account_id = p.account_id AND (q.created < p.created OR (q.created = p.created AND q.id < p.id))",
- )
- .bind(projectId)
- .first<{ n: number }>();
- return Number(before?.n ?? 0) < access.apps ? access : NO_ACCESS;
+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;
}
/** The first plan in plans.json that includes `e` (the plan an upgrade message names), or null. */
@@ -408,8 +449,8 @@ export interface AppLimit {
* limit applies to every account without a plan or grant, whatever the Stripe key.
*/
export async function appLimit(db: D1Like, env: Env, accountId: string, now: Date): Promise {
- const access = await accountAccess(db, accountId, env, now);
- return { plan: access.plan, max: access.apps };
+ const plan = await accountPlan(db, accountId, env, now);
+ return { plan, max: plan.limits.apps };
}
/** The 409 for an account at its plan's app limit: says the limit and where to go, for the page and the CLI. */
@@ -417,10 +458,7 @@ export function appLimitBody(lim: AppLimit, origin: string): { ok: false; error:
const url = `${origin}/account/billing`;
return {
ok: false,
- error:
- lim.plan.per_app === true
- ? `your ${lim.plan.name} plan pays for ${lim.max} app${lim.max === 1 ? "" : "s"}; add apps in Manage billing at ${url}`
- : `the ${lim.plan.name} plan holds ${lim.max} app${lim.max === 1 ? "" : "s"}; see the plans at ${url}`,
+ error: `the ${lim.plan.name} plan holds ${lim.max} app${lim.max === 1 ? "" : "s"}; see the plans at ${url}`,
upgrade_url: url,
};
}
diff --git a/telemetry-worker/src/stripe.ts b/telemetry-worker/src/stripe.ts
index b2d6bc1f..04a743ad 100644
--- a/telemetry-worker/src/stripe.ts
+++ b/telemetry-worker/src/stripe.ts
@@ -62,9 +62,8 @@
// NOTIFY_WEBHOOK) only once Stripe reports that plan's price paid: the subscription's latest invoice (fetched with it)
// is paid and has a positive line on that price (a first invoice, which a switch from another plan is too), seen on
// invoice.paid or on the subscription's own events.
-// - A plan sold by quantity (billing/plans.json per_app: Team since 2026-11-12, or per_seat) is one subscription item
-// whose quantity is the apps (or the seats): the buyer picks it on GenClass's billing page (1 to MAX_QUANTITY),
-// Checkout shows it and lets them change it there, and
+// - A plan sold per seat (billing/plans.json per_seat: Team) is one subscription item whose quantity is the seats: the
+// buyer picks it on GenClass's billing page (1 to MAX_SEATS), Checkout shows it and lets them change it there, and
// afterwards they change it in the Billing Portal (GenClass's configuration allows a quantity update for those
// products only, never a switch of price; scripts/stripe-setup.mjs). The webhook writes the quantity Stripe has now
// to accounts.seats with the plan, so seats follow the subscription like the plan does; src/team.ts reads them.
@@ -73,7 +72,7 @@
import type { Env } from "./collector.js";
import { randomBase62, sha256Hex, type D1Like } from "./dashboard.js";
import { json } from "./http.js";
-import { cleanQuantity, entitled, soldByQuantity, LIVE_STATUSES, LOOKUP_PREFIX, MAX_QUANTITY, ownedPrice, pastDueLapsed, planById, PLANS, PLANS_FILE, planForPrice, stripeMode, type Billing, type Plan, type PlanId } from "./plans.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 { equalStrings } from "./webauthn.js";
export const STRIPE_API = "https://api.stripe.com";
@@ -392,19 +391,21 @@ 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; quantity?: number },
+ o: { account: BillingAccount; customer: string; plan: Plan; origin: string; now: Date; fresh?: boolean; switchFrom?: string; seats?: number; project?: { id: string } },
): Promise {
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 by quantity: the apps or seats asked for, and the buyer can still change it on Stripe's page
- const perSeat = soldByQuantity(o.plan);
- const seats = perSeat ? (cleanQuantity(o.quantity ?? 1) ?? 1) : 1;
+ // 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;
+ 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[] = [];
if (!o.fresh) {
const open = await stripe(env, f, "GET", "/v1/checkout/sessions", { customer: o.customer, status: "open", limit: 20 });
for (const x of Array.isArray(open?.data) ? (open.data as Obj[]) : []) {
if (typeof x?.id !== "string" || !SESSION_ID_RE.test(x.id) || !ownSession(x, o.account.id)) continue;
+ // another project's open checkout is left open: each project is bought on its own page
+ if ((x.metadata?.genclass_project ?? undefined) !== o.project?.id) continue;
const same =
x.metadata?.genclass_plan === o.plan.id &&
(x.metadata?.genclass_switch_from ?? undefined) === o.switchFrom &&
@@ -418,6 +419,9 @@ export async function checkoutSession(
const price = await priceId(env, f, lookup, o.now.getTime(), o.plan);
const mark: Record = { genclass: GENCLASS_MARK, genclass_account: o.account.id };
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 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)}` : "";
@@ -433,10 +437,10 @@ export async function checkoutSession(
mode: "subscription",
customer: o.customer,
client_reference_id: o.account.id,
- line_items: [perSeat ? { price, quantity: seats, adjustable_quantity: { enabled: true, minimum: 1, maximum: MAX_QUANTITY } } : { price, quantity: 1 }],
+ 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.origin}/account/billing?done=1${o.switchFrom ? `&to=${o.plan.id}` : ""}`,
- cancel_url: `${o.origin}/account/billing`,
+ success_url: o.project ? `${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) } : {}) },
// a card (or another payment method) up front
payment_method_collection: "always",
@@ -445,7 +449,7 @@ export async function checkoutSession(
subscription_data: { metadata: mark, billing_mode: { type: "flexible" } },
managed_payments: managedPayments(env, o.plan) ? { enabled: true } : undefined,
},
- `genclass-checkout-${o.account.id}-${o.plan.id}${perSeat ? `-x${seats}` : ""}-${Math.floor(o.now.getTime() / 60_000)}${from}${after}`,
+ `genclass-checkout-${o.account.id}${o.project ? `-p-${o.project.id}` : ""}-${o.plan.id}${perSeat ? `-x${seats}` : ""}-${Math.floor(o.now.getTime() / 60_000)}${from}${after}`,
);
} catch (e) {
priceCache.delete(lookup); // the cached price may have been replaced since
@@ -597,7 +601,7 @@ export interface SubscriptionState {
lookupKey: string | null;
periodEnd: string | null;
cancelAtPeriodEnd: boolean;
- /** For a plan sold by quantity: the quantity of its subscription item, apps or seats (1 to MAX_QUANTITY; a larger one reads as MAX_QUANTITY). Else null. */
+ /** For a plan sold per seat: the quantity of its subscription item (1 to MAX_SEATS; a larger one reads as MAX_SEATS). Else null. */
seats: number | null;
}
@@ -639,7 +643,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 && soldByQuantity(priced) ? (cleanQuantity(quantity) ?? (typeof quantity === "number" && quantity > MAX_QUANTITY ? MAX_QUANTITY : 1)) : null,
+ seats: priced?.per_seat === true ? (cleanSeats(quantity) ?? (typeof quantity === "number" && quantity > MAX_SEATS ? MAX_SEATS : 1)) : null,
};
}
@@ -774,6 +778,12 @@ export function cardOnFile(sub: Obj): boolean {
return !!(idOf(sub?.default_payment_method) ?? idOf(sub?.default_source) ?? idOf(c?.invoice_settings?.default_payment_method) ?? idOf(c?.default_source));
}
+/** The project a per-project subscription is for (its metadata genclass_project, billing/plans.json version 4), or null. */
+export const projectOf = (s: Obj): string | null => {
+ const v = s?.metadata?.genclass_project;
+ return typeof v === "string" && /^[A-Za-z0-9_-]{6,40}$/.test(v) ? v : null;
+};
+
/** The account already has this subscription, live: its plan was granted from it. */
const granted = (account: BillingAccount, sub: Obj): boolean => !!account.subscription_id && idOf(sub?.id) === account.subscription_id && LIVE_STATUSES.has(account.subscription_status ?? "");
@@ -824,6 +834,8 @@ export async function handleEvent(x: EventContext, event: Obj): Promise Math.max(0, PLANS.findIndex((p) => p.id === sub
* when it is not live or not GenClass's. The caller applies `keep` first and then settles `extra` (settleOthers).
*/
async function oneSubscription(x: EventContext, account: BillingAccount, sub: Obj): Promise {
+ if (projectOf(sub)) return null;
const customer = idOf(sub?.customer);
if (!customer || customer !== account.stripe_customer_id || !LIVE_STATUSES.has(sub?.status) || !isGenClassSubscription(sub)) return null;
const all = await customerSubscriptions(x.env, x.fetch, customer);
- const holding = all.filter((s) => HOLDING_STATUSES.has(s.status) && isGenClassSubscription(s) && (granted(account, s) || (paymentCleared(s) && cardOnFile(s))));
+ // a project's subscription is never a duplicate of the account's: each project is paid for on its own
+ const holding = all.filter((s) => !projectOf(s) && HOLDING_STATUSES.has(s.status) && isGenClassSubscription(s) && (granted(account, s) || (paymentCleared(s) && cardOnFile(s))));
// the only one that bills (Stripe may have ended the account's recorded one before its event arrived): it is chosen
if (holding.length < 2) return { keep: sub, extra: [] };
const newest = (a: Obj, b: Obj): number => (Number(b.created) || 0) - (Number(a.created) || 0) || String(b.id).localeCompare(String(a.id));
@@ -1053,6 +1067,53 @@ export async function applySubscription(x: EventContext, account: BillingAccount
return { status: "applied", ours: true, account: account.id, plan: s.plan };
}
+interface ProjectBilling {
+ id: string;
+ name: string;
+ subscription_id: string | null;
+ subscription_status: string | null;
+}
+
+/**
+ * A subscription for one project: the same checks as the account's (provably GenClass's, its first payment cleared,
+ * a card on file for a trial), then its plan, status and period on that project's row. The project must be one of
+ * the account's. A second live subscription for a project that already has one is not written and not cancelled:
+ * the owner is told, so nobody is charged twice without a person looking. Exported for tests.
+ */
+export async function applyProjectSubscription(x: EventContext, account: BillingAccount, sub: Obj, event: Obj = {}): Promise {
+ const subId = idOf(sub?.id);
+ const pid = projectOf(sub);
+ if (!subId || !pid) return ignored("the subscription names no project", event, true);
+ const p = await x.db
+ .prepare("SELECT id, name, subscription_id, subscription_status FROM projects WHERE id = ? AND account_id = ?")
+ .bind(pid, account.id)
+ .first();
+ if (!p) return ignored("the project is not the account's", event, true);
+ const mine = p.subscription_id === subId;
+ const projectGranted = mine && LIVE_STATUSES.has(p.subscription_status ?? "");
+ if (!isGenClassSubscription(sub) && !mine) return ignored("not a GenClass subscription", event, true);
+ if (!projectGranted && LIVE_STATUSES.has(sub?.status) && !paymentCleared(sub)) return ignored("its first payment has not cleared yet", event, true);
+ if (!projectGranted && !cardOnFile(sub)) return ignored("its trial has no saved payment method", event, true);
+ if (p.subscription_id && !mine && LIVE_STATUSES.has(p.subscription_status ?? "")) {
+ if (LIVE_STATUSES.has(sub?.status)) {
+ await notifyOwner(x, {
+ event: "project_second_subscription",
+ text: `${modeNote(x.env)}GenClass account ${account.id}: project ${p.id} has subscription ${p.subscription_id} and a second one, ${subId}, started. Nothing was cancelled: refund and cancel one of them in Stripe.`,
+ account_id: account.id,
+ project_id: p.id,
+ subscription_id: subId,
+ });
+ }
+ return ignored("not the project's current subscription", event, true);
+ }
+ const s = subscriptionState(sub, { now: x.now });
+ await x.db
+ .prepare("UPDATE projects SET plan = ?, subscription_id = ?, subscription_status = ?, current_period_end = ?, cancel_at_period_end = ?, stripe_mode = ? WHERE id = ? AND account_id = ?")
+ .bind(s.plan === "free" ? null : s.plan, subId, s.status, s.periodEnd, s.cancelAtPeriodEnd ? 1 : 0, stripeMode(x.env), p.id, account.id)
+ .run();
+ return { status: "applied", ours: true, account: account.id, plan: s.plan };
+}
+
/** The owner's notice for a payment of a retired plan's subscription (handleEvent, on invoice.paid). */
async function retiredNotice(x: EventContext, account: BillingAccount, sub: Obj): Promise {
const key = subscriptionState(sub).lookupKey;
diff --git a/telemetry-worker/src/usage.ts b/telemetry-worker/src/usage.ts
new file mode 100644
index 00000000..a303a69b
--- /dev/null
+++ b/telemetry-worker/src/usage.ts
@@ -0,0 +1,82 @@
+// What a project has used of its plan this month: the problems GenClass fixed live on visitors' screens (the
+// runtime's interventions in guard or heal mode, and visual fixes), against the plan's limits.live_fixes_per_month
+// (billing/plans.json: 100 on Free, no limit on Team). Past the limit the runtime is told to observe only until the
+// month ends (the owner's decision, 2026-11-12): it keeps finding and reporting every problem, and stops changing
+// anything, which is what observe always is. Paying for the project lifts it at once.
+//
+// Invariants:
+// - The count is the dashboard's own: the daily counters of applied interventions and applied visual fixes for the
+// UTC month so far (src/dashboard.ts aggregate). Nothing new is collected or stored for it.
+// - Ingest's reply may carry one header, OBSERVE_HEADER, when the project is past its limit; the runtime reads it
+// (packages/runtime/src/telemetry) and only ever lowers its mode with it. The batch is stored and counted the same
+// either way, and a failure to work it out leaves the header off: never a reason to drop data or to act more.
+// - The answer is cached a minute per isolate per project, so ingest does not read the counters on every batch.
+
+import type { Env } from "./collector.js";
+import type { D1Like } from "./dashboard.js";
+import { ownerOf } from "./paid.js";
+import { planGatesOpen, stripeMode, type Plan } from "./plans.js";
+
+/** The header on ingest's reply that tells the runtime to observe only; its value says why. */
+export const OBSERVE_HEADER = "x-genclass-observe";
+export const OBSERVE_REASON = "live-fix-limit";
+
+/** The first day of the UTC month of `now` (YYYY-MM-01), and of the month after it (when the limit lifts), as ISO. */
+export const monthOf = (now: Date): { from: string; resets: string } => {
+ const y = now.getUTCFullYear();
+ const m = now.getUTCMonth();
+ return { from: `${now.toISOString().slice(0, 7)}-01`, resets: new Date(Date.UTC(y, m + 1, 1)).toISOString() };
+};
+
+/** The problems GenClass fixed live for this project in the UTC month of `now`. */
+export async function liveFixes(db: D1Like, token: string, now: Date): Promise {
+ const r = await db
+ .prepare("SELECT COALESCE(SUM(count), 0) AS n FROM daily WHERE token = ? AND day >= ? AND (metric = 'intervention' OR (metric = 'check_fix' AND key = 'applied'))")
+ .bind(token, monthOf(now).from)
+ .first<{ n: number }>();
+ return Math.max(0, Math.round(Number(r?.n ?? 0)));
+}
+
+export interface LiveFixUsage {
+ /** Fixed live this month. */
+ used: number;
+ /** The plan's monthly limit; null: none. */
+ limit: number | null;
+ /** Past the limit: the runtime is told to observe only. */
+ observe_only: boolean;
+ /** When the month ends and the count starts again (ISO). */
+ resets: string;
+}
+
+/** A project's live fixes this month against `plan`'s limit. */
+export async function liveFixUsage(db: D1Like, token: string, plan: Plan, now: Date): Promise {
+ const limit = plan.limits.live_fixes_per_month ?? null;
+ const used = await liveFixes(db, token, now);
+ return { used, limit, observe_only: limit !== null && used >= limit, resets: monthOf(now).resets };
+}
+
+const CACHE_MS = 60_000;
+const cache = new Map();
+/** For the tests: forget every cached answer. */
+export const resetUsageCache = (): void => cache.clear();
+
+/**
+ * Whether ingest's reply for this project tells the runtime to observe only: its plan (src/paid.ts ownerOf: the
+ * project's own, or its account's subscription from before plans were per project) has a monthly limit and this
+ * month's live fixes reached it. False on any error and for a token no project has.
+ */
+export async function observeOnly(db: D1Like, env: Env, token: string, now: Date): Promise {
+ const c = cache.get(token);
+ if (c && c.exp > now.getTime()) return c.observe;
+ let observe = false;
+ try {
+ const owner = await ownerOf(db, token, now.getTime(), stripeMode(env), planGatesOpen(env));
+ const limit = owner?.plan.limits.live_fixes_per_month ?? null;
+ observe = limit !== null && (await liveFixes(db, token, now)) >= limit;
+ } catch {
+ observe = false;
+ }
+ if (cache.size > 5000) cache.clear();
+ cache.set(token, { observe, exp: now.getTime() + CACHE_MS });
+ return observe;
+}
diff --git a/telemetry-worker/test/auth.test.ts b/telemetry-worker/test/auth.test.ts
index 07f0a9cd..542bb8e5 100644
--- a/telemetry-worker/test/auth.test.ts
+++ b/telemetry-worker/test/auth.test.ts
@@ -2057,7 +2057,8 @@ describe("accounts: the agent link (POST /api/account/projects//link) and St
expect(html).toContain('
");
+ // no trial: GenClass has none
+ expect(page).not.toMatch(/id="trial"|free trial/);
+ const visible = page.replace(/