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.

+ ${rows}
ProjectPlan
+
`; +} + +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.

' + : v.done + ? `

Thanks. ${name} is on ${esc(a.plan.name)} now. Open its dashboard.

` + : (v.fromCli || v.isNew) && !paid && a.source === null + ? `

${name} is in your account. Choose its plan to go on.

` + : ""; + const test = v.testMode && v.live ? '

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

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

    ${esc(offer.name)}

    ${esc(priceText(offer))}

    ${esc(offer.summary)}

      ${features}
    +
    ${v.live ? `` : 'Paid plans are not on sale yet.'}
    + ${a.locked || a.source !== null ? "" : `

    Try for free (limited)

    + +
    +

    What's stopping you from paying?

    +

    Pick one. It tells us what to fix.

    +
    ${FREE_REASON_WORDS.map(([val, words]) => ``).join("")}
    + + +
    +
    +
    `}` + : paid + ? `
    ` + : ""; + const body = ` +
    +
    +

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

    +

    ${status}

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

    All your projects and their plans

    +
    +
    +`; + 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.` : ""}` }

    `; } else { const over = t.members.some((m) => !m.active); @@ -228,7 +415,7 @@ function teamSection(v: BillingView): string { .map((i) => `Not used yet${time(i.created)}${time(i.expires)}`) .join("")}` : ""; - 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.${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.

    ' : ""} ${members}${invites}
    @@ -302,12 +489,13 @@ ${ manage ? `

    Manage billing

    On Stripe
    -

    Card, invoices${PAID_PLANS.some((p) => p.per_app === true) ? ", apps" : ""}${PAID_PLANS.some((p) => p.per_seat === true) ? ", seats" : ""} and cancellation.

    +

    Card, invoices${PAID_PLANS.some((p) => p.per_seat === true) ? ", seats" : ""} and cancellation.

    ` : "" } +${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('
    '); expect(html).toContain(""); expect(html).toContain('gcPost("/api/account/projects", { name: name.value })'); - expect(html).toContain('"/account/apps/" + encodeURIComponent(j.project.id) + "#connect"'); + // a new app goes to its plan page first (Team large, Free small), which goes on to Connect + expect(html).toContain('"/account/billing?project=" + encodeURIComponent(j.project.id) + "&new=1"'); expect(html).toContain('Cancel'); // the Projects page offers it, and lists each project as a card await call(env, "POST", "/api/account/projects", { body: { name: "Shop" }, cookie }); @@ -2638,7 +2639,7 @@ describe("POST /start signed in: the one click puts the app in the account", () const cookie = sessionOf(up.res); const form = await call(env, "POST", "/start", { cookie }); expect(form.status).toBe(303); - expect(form.headers.get("location")).toMatch(/^\/account\/apps\/[^/]+#connect$/); + expect(form.headers.get("location")).toMatch(/^\/account\/billing\?project=[^&/]+&new=1$/); const owned = sql.rows("SELECT token, account_id, private_link FROM projects"); expect(owned).toHaveLength(1); expect(owned[0]).toMatchObject({ account_id: up.json.account.id, private_link: 0 }); diff --git a/telemetry-worker/test/billing.test.ts b/telemetry-worker/test/billing.test.ts index 28ae288b..7fcee9b4 100644 --- a/telemetry-worker/test/billing.test.ts +++ b/telemetry-worker/test/billing.test.ts @@ -1490,31 +1490,23 @@ describe("Managed Payments for Pro, flexible billing mode, switching between Pro // ------------------------------------------------------------------------------------------------- limits describe("plans: the Free plan's limits (apps, dashboard ranges)", () => { - it("a fourth app on Free is refused with a link to upgrade, created or claimed; Pro may add it", async () => { - const { env, sql } = setup(LIVE); - const { cookie, account } = await signedIn(env); + it("a fourth app on Free is created or claimed, and waits for a plan of its own (plans per project, version 4)", async () => { + const { env } = setup(LIVE); + const { cookie } = await signedIn(env); for (const n of ["a", "b", "c"]) await createApp(env, cookie, n); const r = await call(env, "POST", "/api/account/projects", { body: { name: "d" }, cookie }); - expect(r.status).toBe(409); - expect(await r.json()).toEqual({ - ok: false, - error: "the Free plan holds 3 apps; see the plans at https://genclass.dev/account/billing", - upgrade_url: "https://genclass.dev/account/billing", - }); + expect(r.status).toBe(201); const anon = (await (await call(env, "POST", "/api/projects", { body: { name: "Old" }, origin: null })).json()) as any; - expect((await call(env, "POST", "/api/account/projects/claim", { body: { dashboard_url: anon.dashboardUrl }, cookie })).status).toBe(409); - setPlan(sql, account.id, "pro"); - expect((await call(env, "POST", "/api/account/projects", { body: { name: "d" }, cookie })).status).toBe(201); expect((await call(env, "POST", "/api/account/projects/claim", { body: { dashboard_url: anon.dashboardUrl }, cookie })).status).toBe(200); }); - it("without any Stripe key the Free plan's app limit applies too; GenClass's grant lifts it", async () => { + it("without any Stripe key a fourth app is created all the same; GenClass's account-wide grant covers it", async () => { const { env, sql } = setup(); const { cookie, account } = await signedIn(env); for (const n of ["a", "b", "c"]) await createApp(env, cookie, n); - expect((await call(env, "POST", "/api/account/projects", { body: { name: "d" }, cookie })).status).toBe(409); + expect((await call(env, "POST", "/api/account/projects", { body: { name: "d" }, cookie })).status).toBe(201); setPlan(sql, account.id, "free", { comp_until: "9999-12-31T00:00:00.000Z" }); - for (const n of ["d", "e"]) await createApp(env, cookie, n); + for (const n of ["e", "f"]) await createApp(env, cookie, n); }); it("ranges: Free has no dashboard (its stats answer 402); Pro and Custom reach a year; a longer ask gets the longest shown", async () => { @@ -1634,8 +1626,6 @@ describe("GET /account/billing", () => { access: null, // the fixture's plans set no seats: no limit seats: null, - // the fixture's Free holds 3 apps - apps: 3, }); setPlan(sql, a.account.id, "custom", { subscription_id: "sub_c000000001", subscription_status: "active", current_period_end: "2026-11-13T12:00:00.000Z" }); const html = await (await call(env, "GET", "/account/billing", { cookie: a.cookie })).text(); @@ -1775,9 +1765,9 @@ describe("Stripe test and live mode", () => { expect(page).not.toContain("data-plan="); expect(page).not.toContain("Stripe test mode"); expect(((await (await call(env, "GET", "/api/billing", { cookie: other.cookie })).json()) as any).on_sale).toBe(false); - // the Free plan's app limit applies whatever the key + // a fourth app is created whatever the key (it waits for a plan of its own) for (const n of ["a", "b", "c"]) await createApp(env, other.cookie, n); - expect((await call(env, "POST", "/api/account/projects", { body: { name: "d" }, cookie: other.cookie })).status).toBe(409); + expect((await call(env, "POST", "/api/account/projects", { body: { name: "d" }, cookie: other.cookie })).status).toBe(201); }); it("the webhook takes the test secret with a test key, and ignores (200) the other mode's events after going live", async () => { diff --git a/telemetry-worker/test/billing.test.ts.orig b/telemetry-worker/test/billing.test.ts.orig new file mode 100644 index 00000000..3401937f --- /dev/null +++ b/telemetry-worker/test/billing.test.ts.orig @@ -0,0 +1,1848 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resetTokenCache } from "../src/dashboard.js"; +import { resetOwnerCache } from "../src/paid.js"; +import { ACCOUNT_RANGES, canBuy, PAST_DUE_GRACE_DAYS, PLANS, pickRange, planByLookupKey, planForPrice, planRetention, priceText, stripeMode, viewRanges } from "../src/plans.js"; +import { form, invoicePaysCustom, invoiceSubscription, isGenClassSubscription, latestInvoicePaysCustom, managedPayments, paymentCleared, resetStripeCache, sharedCustomer, STRIPE_VERSION, subscriptionState, verifySignature, WEBHOOK_EVENTS } from "../src/stripe.js"; +import { + advance, + call, + cliToken, + createApp, + deliver, + fakeStripe, + GENCLASS_PORTAL, + invoice, + paymentIntentFor, + refusingLimiter, + resetClock, + setPlan, + setup, + signedIn, + stripeEvent, + subscription, + WEBHOOK_SECRET, + now, + price, +} from "./account-helpers.js"; + +// Paid plans (src/plans.ts, src/stripe.ts, src/billing.ts): the Stripe webhook (signature, idempotency, plan from the +// subscription), Checkout and the Billing Portal with fetch mocked, CSRF, the Free plan's limits, the dashboard ranges +// per plan, the billing page, and account deletion with a live subscription. +// +// On the plans as they were before 2026-10-19 (test/fixtures/plans-two-paid.json: Free without a dashboard and 3 apps, +// Pro sold through Managed Payments, Custom), so switches and duplicates between two paid plans, Managed Payments and +// the Free plan's limits stay tested; nothing in src/ names those plans. The real plans are test/plans.test.ts's. +vi.mock("../../billing/plans.json", async () => ({ default: (await import("./fixtures/plans-two-paid.json")).default })); + +// a live-mode key: paid plans on sale to everyone, the Free plan's limits on (fetch is always the fake below) +const LIVE = { STRIPE_SECRET_KEY: "sk_live_fake", STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET, STRIPE_PORTAL_CONFIGURATION: GENCLASS_PORTAL }; +const TEST_SECRET = "whsec_test_mode_0123456789"; + +beforeEach(() => { + resetClock(); + resetTokenCache(); + resetOwnerCache(); + resetStripeCache(); +}); + +const enc = (s: string) => new TextEncoder().encode(s); + +// ------------------------------------------------------------------------------------------------- signature + +describe("Stripe webhook signature (Stripe-Signature: t=, v1=; HMAC-SHA256 of t.payload)", () => { + const body = enc('{"id":"evt_1","type":"invoice.paid"}'); + const t = 1_790_000_000; + async function header(secret: string, ts = t, payload = body) { + const k = await crypto.subtle.importKey("raw", enc(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const msg = new Uint8Array([...enc(`${ts}.`), ...payload]); + return [...new Uint8Array(await crypto.subtle.sign("HMAC", k, msg))].map((b) => b.toString(16).padStart(2, "0")).join(""); + } + it("accepts a valid signature, and any one valid v1 among several (secret rotation)", async () => { + const good = await header(WEBHOOK_SECRET); + expect(await verifySignature(body, `t=${t},v1=${good}`, WEBHOOK_SECRET, t + 10)).toBe("ok"); + const other = await header("whsec_old"); + expect(await verifySignature(body, `t=${t},v1=${other},v1=${good},v0=abc`, WEBHOOK_SECRET, t)).toBe("ok"); + }); + it("refuses a wrong secret, a changed body, an old or future timestamp, and malformed headers", async () => { + const good = await header(WEBHOOK_SECRET); + expect(await verifySignature(body, `t=${t},v1=${await header("whsec_wrong")}`, WEBHOOK_SECRET, t)).toMatch(/does not match/); + expect(await verifySignature(enc('{"id":"evt_2"}'), `t=${t},v1=${good}`, WEBHOOK_SECRET, t)).toMatch(/does not match/); + expect(await verifySignature(body, `t=${t},v1=${good}`, WEBHOOK_SECRET, t + 301)).toMatch(/tolerance/); + expect(await verifySignature(body, `t=${t},v1=${good}`, WEBHOOK_SECRET, t - 301)).toMatch(/tolerance/); + expect(await verifySignature(body, `t=${t + 1},v1=${good}`, WEBHOOK_SECRET, t)).toMatch(/does not match/); + expect(await verifySignature(body, null, WEBHOOK_SECRET, t)).toMatch(/no Stripe-Signature/); + expect(await verifySignature(body, `v1=${good}`, WEBHOOK_SECRET, t)).toMatch(/no timestamp/); + expect(await verifySignature(body, `t=${t}`, WEBHOOK_SECRET, t)).toMatch(/no v1/); + expect(await verifySignature(body, `t=${t},v0=${good}`, WEBHOOK_SECRET, t)).toMatch(/no v1/); + }); +}); + +// ------------------------------------------------------------------------------------------------- REST encoding + +describe("Stripe requests: form encoding, version, idempotency", () => { + it("encodes nested objects and arrays the way Stripe reads them, leaving out null and undefined", () => { + const s = form({ mode: "subscription", line_items: [{ price: "price_1", quantity: 1 }], metadata: { a: "x y" }, skip: null, gone: undefined, lookup_keys: ["k"] }); + expect(new URLSearchParams(s).toString()).toBe(s); + expect([...new URLSearchParams(s)]).toEqual([ + ["mode", "subscription"], + ["line_items[0][price]", "price_1"], + ["line_items[0][quantity]", "1"], + ["metadata[a]", "x y"], + ["lookup_keys[0]", "k"], + ]); + }); +}); + +// ------------------------------------------------------------------------------------------------- Checkout, portal + +describe("POST /api/billing/checkout and /api/billing/portal", () => { + it("checkout: a GenClass customer is created once with the account's email, then a subscription session by lookup_key", async () => { + const { env, sql } = setup(LIVE); + const { cookie, account } = await signedIn(env); + const s = fakeStripe(); + const r = await call(env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie, fetch: s.fetch }); + expect(r.status).toBe(200); + expect(await r.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000001" }); + expect(s.calls.map((x) => `${x.method} ${x.url.pathname}`)).toEqual(["POST /v1/customers", "GET /v1/prices", "POST /v1/checkout/sessions"]); + const [customer, prices, session] = s.calls; + expect(prices!.url.searchParams.get("lookup_keys[0]")).toBe("genclass_pro_monthly"); + expect(prices!.url.searchParams.get("active")).toBe("true"); + expect(prices!.url.searchParams.get("expand[0]")).toBe("data.product"); + // GenClass's own customer, marked as such: never one found by email (the Stripe account is shared with the other business) + expect(Object.fromEntries(customer!.form!)).toEqual({ email: "ada@example.com", description: "GenClass account", "metadata[genclass]": "1", "metadata[genclass_account]": account.id }); + expect(customer!.headers["idempotency-key"]).toBe(`genclass-customer-${account.id}`); + expect(Object.fromEntries(session!.form!)).toMatchObject({ + mode: "subscription", + customer: "cus_test0001", + client_reference_id: account.id, + "line_items[0][price]": "price_pro000001", + "line_items[0][quantity]": "1", + success_url: "https://genclass.dev/account/billing?done=1", + cancel_url: "https://genclass.dev/account/billing", + "metadata[genclass]": "1", + "metadata[genclass_account]": account.id, + "metadata[genclass_plan]": "pro", + "subscription_data[metadata][genclass]": "1", + "subscription_data[metadata][genclass_account]": account.id, + "subscription_data[billing_mode][type]": "flexible", + }); + // Managed Payments only once the owner turns it on (MANAGED_PAYMENTS unset here) + expect(session!.form!.has("managed_payments[enabled]")).toBe(false); + for (const c of s.calls) { + expect(c.headers.authorization).toBe("Bearer sk_live_fake"); + expect(c.headers["stripe-version"]).toBe(STRIPE_VERSION); + } + expect(session!.headers["content-type"]).toBe("application/x-www-form-urlencoded"); + expect(session!.headers["idempotency-key"]).toMatch(new RegExp(`^genclass-checkout-${account.id}-pro-\\d+$`)); + expect(sql.rows("SELECT stripe_customer_id, stripe_mode, plan FROM accounts")).toEqual([{ stripe_customer_id: "cus_test0001", stripe_mode: "live", plan: "free" }]); + + // again (Custom this time): the customer is reused, Stripe is asked for its subscriptions and open Checkout pages, + // the unpaid Pro page is closed, and the price comes from Custom's own lookup key + s.calls.length = 0; + const second = await call(env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie, fetch: s.fetch }); + expect(await second.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000002" }); + // the stored customer is checked first: it must be the account's own GenClass customer + expect(s.calls.map((x) => `${x.method} ${x.url.pathname}`)).toEqual([ + "GET /v1/customers/cus_test0001", + "GET /v1/subscriptions", + "GET /v1/checkout/sessions", + "POST /v1/checkout/sessions/cs_test_000001/expire", + "GET /v1/prices", + "POST /v1/checkout/sessions", + ]); + expect(s.calls[1]!.url.searchParams.get("customer")).toBe("cus_test0001"); + expect(s.calls[1]!.url.searchParams.get("status")).toBe("all"); + expect(s.calls[2]!.url.searchParams.get("status")).toBe("open"); + expect(s.sessions.get("cs_test_000001")!.status).toBe("expired"); + const made = s.calls[5]!; + expect(made.form!.get("line_items[0][price]")).toBe("price_custom0001"); + expect(made.form!.get("customer")).toBe("cus_test0001"); + // after closing a page the idempotency key changes, so Stripe cannot hand back the page it just closed + expect(made.headers["idempotency-key"]).toMatch(new RegExp(`^genclass-checkout-${account.id}-custom-\\d+-after-[0-9a-f]{16}$`)); + + // a second click on Custom (or a second tab) gets the same open page, not a second one + s.calls.length = 0; + const third = await call(env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie, fetch: s.fetch }); + expect(await third.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000002" }); + expect(s.calls.map((x) => `${x.method} ${x.url.pathname}`)).toEqual(["GET /v1/customers/cus_test0001", "GET /v1/subscriptions", "GET /v1/checkout/sessions"]); + }); + + it("CSRF and sessions: both POSTs need the browser session and our Origin; a CLI token and bad input are refused", async () => { + const { env } = setup(LIVE); + const { cookie } = await signedIn(env); + const bearer = await cliToken(env, cookie); + const s = fakeStripe(); + for (const path of ["/api/billing/checkout", "/api/billing/portal"]) { + expect((await call(env, "POST", path, { body: { plan: "pro" }, cookie, origin: null, fetch: s.fetch })).status).toBe(403); + expect((await call(env, "POST", path, { body: { plan: "pro" }, cookie, origin: "https://evil.example", fetch: s.fetch })).status).toBe(403); + expect((await call(env, "POST", path, { body: { plan: "pro" }, fetch: s.fetch })).status).toBe(401); + expect((await call(env, "POST", path, { body: { plan: "pro" }, bearer, origin: null, fetch: s.fetch })).status).toBe(403); + expect((await call(env, "POST", path, { body: "plan=pro", cookie, headers: { "content-type": "application/x-www-form-urlencoded" }, fetch: s.fetch })).status).toBe(415); + expect((await call(env, "GET", path, { cookie, fetch: s.fetch })).status).toBe(405); + } + expect(s.calls).toEqual([]); + expect((await call(env, "POST", "/api/billing/checkout", { body: { plan: "free" }, cookie, fetch: s.fetch })).status).toBe(400); + expect((await call(env, "POST", "/api/billing/checkout", { body: { plan: "enterprise" }, cookie, fetch: s.fetch })).status).toBe(400); + // nothing to manage before a first purchase + expect((await call(env, "POST", "/api/billing/portal", { body: {}, cookie, fetch: s.fetch })).status).toBe(409); + // not on sale without the secret key + const off = setup(); + const o = await signedIn(off.env); + expect((await call(off.env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie: o.cookie, fetch: s.fetch })).status).toBe(503); + }); + + it("portal: a session for the account's checked customer, with GenClass's own configuration, never the account's default", async () => { + const { env, sql } = setup(LIVE); + const { cookie, account } = await signedIn(env); + setPlan(sql, account.id, "pro", { stripe_customer_id: "cus_known0001", subscription_id: "sub_known0001", subscription_status: "active" }); + const s = fakeStripe(); + s.genclassCustomer("cus_known0001", account.id); + s.subs.set("sub_known0001", subscription("sub_known0001", account.id, "cus_known0001", "genclass_pro_monthly", "active", { latest_invoice: "in_known0001" })); + s.invoices.set("in_known0001", invoice("in_known0001", "cus_known0001", "sub_known0001", [["price_pro000001", 9900]])); + const r = await call(env, "POST", "/api/billing/portal", { body: {}, cookie, fetch: s.fetch }); + expect(r.status).toBe(200); + expect(await r.json()).toEqual({ url: "https://billing.stripe.com/p/session/test_000001" }); + // the customer, everything on it, then GenClass's configuration by its id (no configuration is listed) + expect(s.calls.map((c) => `${c.method} ${c.url.pathname}`)).toEqual([ + "GET /v1/customers/cus_known0001", + "GET /v1/subscriptions", + "GET /v1/invoices", + `GET /v1/billing_portal/configurations/${GENCLASS_PORTAL}`, + "POST /v1/billing_portal/sessions", + ]); + const sess = s.calls.find((c) => c.url.pathname === "/v1/billing_portal/sessions")!; + expect(Object.fromEntries(sess.form!)).toEqual({ customer: "cus_known0001", return_url: "https://genclass.dev/account/billing", configuration: GENCLASS_PORTAL }); + }); + + it("a paying account asking for the other plan gets a switch: a Checkout replacing its subscription, never the portal", async () => { + const { env, sql } = setup(LIVE); + const { cookie, account } = await signedIn(env); + setPlan(sql, account.id, "pro", { stripe_customer_id: "cus_known0001", subscription_id: "sub_known0001", subscription_status: "active", stripe_mode: "live" }); + const s = fakeStripe(); + s.genclassCustomer("cus_known0001", account.id); + s.subs.set("sub_known0001", subscription("sub_known0001", account.id, "cus_known0001", "genclass_pro_monthly")); + const r = await call(env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie, fetch: s.fetch }); + expect(r.status).toBe(200); + expect(await r.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000001", switch: true }); + const sess = s.calls.find((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions")!; + expect(sess.form!.get("subscription_data[metadata][genclass_switch_from]")).toBe("sub_known0001"); + expect(s.calls.some((c) => c.url.pathname.startsWith("/v1/billing_portal"))).toBe(false); + expect((await call(env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie, fetch: s.fetch })).status).toBe(409); + }); + + it("what Stripe has decides, not the database: a payment whose webhook has not arrived yet blocks a second purchase", async () => { + const { env, sql } = setup(LIVE); + const { cookie, account } = await signedIn(env); + // the database still says Free (no webhook yet), Stripe already has the Pro subscription + setPlan(sql, account.id, "free", { stripe_customer_id: "cus_late000001", stripe_mode: "live" }); + const s = fakeStripe(); + s.genclassCustomer("cus_late000001", account.id); + s.subs.set("sub_late000001", subscription("sub_late000001", account.id, "cus_late000001", "genclass_pro_monthly")); + const again = await call(env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie, fetch: s.fetch }); + expect(again.status).toBe(409); + expect(((await again.json()) as any).error).toBe("you are already on the Pro plan"); + expect(s.calls.some((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions")).toBe(false); + // the other plan is a switch from the subscription Stripe has + const other = await call(env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie, fetch: s.fetch }); + expect(((await other.json()) as any).switch).toBe(true); + expect(s.calls.find((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions")!.form!.get("metadata[genclass_switch_from]")).toBe("sub_late000001"); + // a cancelled-at-period-end plan says how to keep it + s.subs.get("sub_late000001")!.cancel_at_period_end = true; + expect(((await (await call(env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie, fetch: s.fetch })).json()) as any).error).toBe( + "you are already on the Pro plan; to keep it after this period, resume it in Manage billing", + ); + }); + + it("a stored customer Stripe no longer has is replaced at checkout, and forgotten by the portal", async () => { + const { env, sql } = setup(LIVE); + const { cookie, account } = await signedIn(env); + setPlan(sql, account.id, "free", { stripe_customer_id: "cus_gone000001", stripe_mode: "live" }); + const missing = () => new Response(JSON.stringify({ error: { code: "resource_missing", param: "customer", message: "No such customer: 'cus_gone000001'" } }), { status: 400 }); + // the fake has no such customer: GET /v1/customers/cus_gone000001 answers 404 resource_missing + const s = fakeStripe(); + const r = await call(env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie, fetch: s.fetch }); + expect(r.status).toBe(200); + expect(s.calls.map((x) => `${x.method} ${x.url.pathname}`)).toEqual(["GET /v1/customers/cus_gone000001", "POST /v1/customers", "GET /v1/prices", "POST /v1/checkout/sessions"]); + expect(s.calls[1]!.headers["idempotency-key"]).toBe(`genclass-customer-${account.id}-after-cus_gone000001`); + expect(sql.rows("SELECT stripe_customer_id, stripe_mode FROM accounts")).toEqual([{ stripe_customer_id: "cus_test0001", stripe_mode: "live" }]); + + setPlan(sql, account.id, "pro", { stripe_customer_id: "cus_gone000002", subscription_id: "sub_gone000002", subscription_status: "active", stripe_mode: "live" }); + const p = fakeStripe(); + const pr = await call(env, "POST", "/api/billing/portal", { body: {}, cookie, fetch: p.fetch }); + expect(pr.status).toBe(409); + expect(sql.rows("SELECT plan, stripe_customer_id, subscription_id FROM accounts")).toEqual([{ plan: "free", stripe_customer_id: null, subscription_id: null }]); + expect(p.calls.some((c) => c.url.pathname === "/v1/billing_portal/sessions")).toBe(false); + // ... also when the customer goes missing between the check and the session + setPlan(sql, account.id, "pro", { stripe_customer_id: "cus_gone000003", subscription_id: null, subscription_status: null, stripe_mode: "live" }); + const q = fakeStripe({ answer: (u) => (u.pathname === "/v1/billing_portal/sessions" ? missing() : undefined) }); + q.genclassCustomer("cus_gone000003", account.id); + expect((await call(env, "POST", "/api/billing/portal", { body: {}, cookie, fetch: q.fetch })).status).toBe(409); + expect(sql.rows("SELECT stripe_customer_id FROM accounts")).toEqual([{ stripe_customer_id: null }]); + }); + + it("checkout, portal, Send test, new export keys and the export answer 429 once an account is over its budget", async () => { + const { env, sql } = setup(LIVE); + const { cookie, account } = await signedIn(env); + const app = await createApp(env, cookie); + // from here on every budget is used up (sign-in itself has one too, so it is set after) + env.AUTH_LIMIT = refusingLimiter; + env.EXPORT_LIMIT = refusingLimiter; + const s = fakeStripe(); + const r = await call(env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie, fetch: s.fetch }); + expect(r.status).toBe(429); + expect(r.headers.get("retry-after")).toBe("60"); + expect(s.calls).toEqual([]); + setPlan(sql, account.id, "pro", { stripe_customer_id: "cus_rate000001", stripe_mode: "live" }); + expect((await call(env, "POST", "/api/billing/portal", { body: {}, cookie, fetch: s.fetch })).status).toBe(429); + expect((await call(env, "POST", "/api/account/export-keys", { body: {}, cookie })).status).toBe(429); + expect((await call(env, "GET", `/api/account/projects/${app.id}/events`, { cookie })).status).toBe(429); + expect(s.calls).toEqual([]); + }); + + it("a Stripe refusal or outage is a 502 with Stripe's reason, and nothing is saved", async () => { + const { env, sql } = setup(LIVE); + const { cookie } = await signedIn(env); + const s = fakeStripe({ answer: (u) => (u.pathname === "/v1/customers" ? new Response(JSON.stringify({ error: { message: "Invalid API Key provided" } }), { status: 401 }) : undefined) }); + const r = await call(env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie, fetch: s.fetch }); + expect(r.status).toBe(502); + expect(((await r.json()) as any).error).toBe("Stripe: Invalid API Key provided"); + expect(sql.rows("SELECT stripe_customer_id FROM accounts")).toEqual([{ stripe_customer_id: null }]); + const down = await call(env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie, fetch: async () => { throw new Error("offline"); } }); + expect(down.status).toBe(502); + }); +}); + +// ------------------------------------------------------------------------------------------------- webhook + +describe("POST /api/billing/webhook: the plan follows the subscription", () => { + async function paying(planKey = "genclass_pro_monthly") { + const t = setup({ ...LIVE, NOTIFY_WEBHOOK: "https://hooks.example.com/notify" }); + const a = await signedIn(t.env); + const s = fakeStripe(); + t.sql.db.prepare("UPDATE accounts SET stripe_customer_id = 'cus_ada0000001', stripe_mode = 'live' WHERE id = ?").run(a.account.id); + s.genclassCustomer("cus_ada0000001", a.account.id); + s.subs.set("sub_ada0000001", subscription("sub_ada0000001", a.account.id, "cus_ada0000001", planKey)); + const plan = () => t.sql.rows("SELECT plan, subscription_id, subscription_status, current_period_end, cancel_at_period_end FROM accounts")[0]!; + return { ...t, ...a, s, plan }; + } + // what Stripe sends when GenClass's Checkout has started the subscription + const subscribed = (t: { s: ReturnType }) => stripeEvent("customer.subscription.created", t.s.subs.get("sub_ada0000001")!); + + it("customer.subscription.created: Pro, from the subscription's lookup key; the same event again changes nothing", async () => { + const t = await paying(); + const ev = subscribed(t); + const r = await deliver(t.env, ev, t.s.fetch); + expect(r.status).toBe(200); + expect(await r.json()).toEqual({ received: true, result: "applied" }); + expect(t.plan()).toEqual({ plan: "pro", subscription_id: "sub_ada0000001", subscription_status: "active", current_period_end: new Date(1_792_000_000_000).toISOString(), cancel_at_period_end: 0 }); + // the subscription (with its prices' products, its customer and its latest invoice), then the customer's + // subscriptions (one subscription per account) + expect(t.s.calls.map((c) => `${c.method} ${c.url.pathname}`)).toEqual(["GET /v1/subscriptions/sub_ada0000001", "GET /v1/subscriptions"]); + expect(t.s.calls[0]!.url.searchParams.get("expand[0]")).toBe("items.data.price.product"); + expect(t.s.calls[0]!.url.searchParams.get("expand[1]")).toBe("customer"); + expect(t.s.calls[0]!.url.searchParams.get("expand[2]")).toBe("latest_invoice"); + expect(t.s.calls[1]!.url.searchParams.get("expand[0]")).toBe("data.items.data.price.product"); + expect(t.sql.rows("SELECT stripe_mode FROM accounts")).toEqual([{ stripe_mode: "live" }]); + expect(t.sql.rows("SELECT id, type FROM billing_events")).toEqual([{ id: ev.id, type: "customer.subscription.created" }]); + // a redelivery is recognised by its id: no second fetch, no change + t.s.subs.get("sub_ada0000001")!.status = "canceled"; + const again = await deliver(t.env, ev, t.s.fetch); + expect(await again.json()).toEqual({ received: true, duplicate: true }); + expect(t.s.calls).toHaveLength(2); + expect(t.plan().plan).toBe("pro"); + }); + + it("every event type moves the plan the way the subscription's status says", async () => { + const t = await paying(); + const sub = t.s.subs.get("sub_ada0000001")!; + const send = async (type: string, obj: Record) => { + const r = await deliver(t.env, stripeEvent(type, obj), t.s.fetch); + expect(r.status, type).toBe(200); + return t.plan(); + }; + expect((await send("customer.subscription.created", sub)).plan).toBe("pro"); + // cancel at period end: still Pro, flagged, ends at the period end + sub.cancel_at_period_end = true; + expect(await send("customer.subscription.updated", sub)).toMatchObject({ plan: "pro", cancel_at_period_end: 1 }); + sub.cancel_at_period_end = false; + // a failed renewal: past_due keeps Pro while Stripe retries (for 14 days at most: the past-due tests below) + sub.status = "past_due"; + expect(await send("customer.subscription.updated", sub)).toMatchObject({ plan: "pro", subscription_status: "past_due" }); + // paid after all (no invoice event: the status change arrives as customer.subscription.updated) + sub.status = "active"; + expect(await send("customer.subscription.updated", sub)).toMatchObject({ plan: "pro", subscription_status: "active" }); + // switched to Custom in the portal + sub.items.data[0].price = price("genclass_custom_monthly"); + expect((await send("customer.subscription.updated", sub)).plan).toBe("custom"); + sub.status = "unpaid"; + expect(await send("customer.subscription.updated", sub)).toMatchObject({ plan: "free", subscription_status: "unpaid" }); + sub.status = "canceled"; + expect(await send("customer.subscription.deleted", sub)).toMatchObject({ plan: "free", subscription_status: "canceled", cancel_at_period_end: 0 }); + expect(t.sql.rows("SELECT COUNT(*) AS n FROM billing_events")[0]!.n).toBe(7); + }); + + it("subscriptionState: active and trialing give the plan, past_due keeps it, anything else is free; both API shapes", () => { + const base = subscription("sub_x00000001", "a".repeat(22), "cus_x00000001", "genclass_pro_monthly"); + for (const [status, plan] of [["active", "pro"], ["trialing", "pro"], ["past_due", "pro"], ["incomplete", "free"], ["incomplete_expired", "free"], ["canceled", "free"], ["unpaid", "free"], ["paused", "free"]] as const) { + expect(subscriptionState({ ...base, status }).plan, status).toBe(plan); + } + expect(subscriptionState({ ...base, items: { data: [{ price: { lookup_key: "old_plan_key" } }] } })).toMatchObject({ plan: "free", priced: null, lookupKey: "old_plan_key" }); + // a price the setup script replaced keeps its product and gets a genclass_..._retired_ key: its subscribers keep the plan + const retired = price("genclass_pro_monthly_retired_price_old000001", { id: "price_old000001", plan: "pro" }); + expect(subscriptionState({ ...base, items: { data: [{ price: retired }] } })).toMatchObject({ plan: "pro", priced: "pro", lookupKey: "genclass_pro_monthly_retired_price_old000001" }); + // provably GenClass's only: a genclass_ lookup key AND a product with metadata genclass_plan naming a paid plan + expect(planForPrice(price("genclass_custom_monthly"))?.id).toBe("custom"); + expect(planForPrice({ ...price("genclass_pro_monthly"), product: "genclass_pro" })).toBeNull(); // product not expanded + expect(planForPrice(price("genclass_pro_monthly", { plan: null }))).toBeNull(); // a product without the metadata + expect(planForPrice(price("other_box_monthly", { plan: "pro" }))).toBeNull(); // not a GenClass lookup key + expect(planForPrice({ id: "price_1", lookup_key: null, metadata: { genclass_plan: "pro" }, product: { metadata: { genclass_plan: "pro" } } })).toBeNull(); + expect(planForPrice(price("genclass_pro_monthly", { plan: "custom" }))).toBeNull(); // the key and the product disagree + // metadata can only name a paid plan in plans.json + expect(planForPrice(price("genclass_free", { plan: "free" }))).toBeNull(); + expect(planForPrice(price("genclass_enterprise", { plan: "enterprise" }))).toBeNull(); + for (const p of PLANS) for (const k of Object.values(p.stripe_lookup_keys)) if (k) expect(k.startsWith("genclass_")).toBe(true); + // every price must be GenClass's for the subscription to be + expect(isGenClassSubscription(base)).toBe(true); + expect(isGenClassSubscription({ ...base, items: { data: [...base.items.data, { price: price("other_box_monthly") }] } })).toBe(false); + expect(isGenClassSubscription({ ...base, items: { data: [] } })).toBe(false); + // cancelled at once (deletion, a refund, dunning): the period ends when it ended, not at the paid period's end + expect(subscriptionState({ ...base, status: "canceled", ended_at: 1_791_000_000 })).toMatchObject({ plan: "free", periodEnd: new Date(1_791_000_000_000).toISOString(), cancelAtPeriodEnd: false }); + expect(subscriptionState({ ...base, status: "active", ended_at: 1_791_000_000 }).periodEnd).toBe(new Date(1_792_000_000_000).toISOString()); + // before API version 2025-03-31 the period end was on the subscription + expect(subscriptionState({ ...base, current_period_end: 1_700_000_000, items: { data: [{ price: { lookup_key: "genclass_pro_monthly" } }] } }).periodEnd).toBe(new Date(1_700_000_000_000).toISOString()); + expect(subscriptionState({ ...base, cancel_at: 1_795_000_000 })).toMatchObject({ cancelAtPeriodEnd: true, periodEnd: new Date(1_795_000_000_000).toISOString() }); + expect(invoiceSubscription({ subscription: "sub_old0000001" })).toBe("sub_old0000001"); + expect(invoiceSubscription({ parent: { subscription_details: { subscription: "sub_new0000001" } } })).toBe("sub_new0000001"); + expect(invoiceSubscription({ lines: { data: [{ parent: { subscription_item_details: { subscription: "sub_line000001" } } }] } })).toBe("sub_line000001"); + expect(invoiceSubscription({})).toBeNull(); + // the subscription events, invoice.paid (a first payment that clears after "active") and the refund and dispute + // notices; no Checkout or customer events, which bring buyers' details and nothing the worker needs + expect(WEBHOOK_EVENTS).toEqual(["customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted", "invoice.paid", "charge.refunded", "charge.dispute.created"]); + }); + + it("an older subscription ending does not downgrade an account that has a newer live one", async () => { + const t = await paying(); + setPlan(t.sql, t.account.id, "pro", { subscription_id: "sub_new0000001", subscription_status: "active" }); + t.s.subs.set("sub_old0000001", subscription("sub_old0000001", t.account.id, "cus_ada0000001", "genclass_pro_monthly", "canceled")); + const r = await deliver(t.env, stripeEvent("customer.subscription.deleted", t.s.subs.get("sub_old0000001")!), t.s.fetch); + expect(await r.json()).toEqual({ received: true, result: "ignored" }); + expect(t.plan()).toMatchObject({ plan: "pro", subscription_id: "sub_new0000001" }); + }); + + it("events for a customer GenClass does not know are logged by type and id and ignored, with no Stripe call", async () => { + const t = await paying(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + t.s.subs.set("sub_zzz0000001", subscription("sub_zzz0000001", "Z".repeat(22), "cus_zzz0000001", "genclass_pro_monthly")); + const ev = stripeEvent("customer.subscription.updated", t.s.subs.get("sub_zzz0000001")!); + const r = await deliver(t.env, ev, t.s.fetch); + expect(await r.json()).toEqual({ received: true, result: "ignored" }); + expect(log).toHaveBeenCalledWith(`stripe webhook: customer.subscription.updated ${ev.id} ignored: not a GenClass customer`); + expect(t.s.calls).toEqual([]); + log.mockRestore(); + expect(t.plan().plan).toBe("free"); + expect((await deliver(t.env, stripeEvent("customer.created", { id: "cus_1" }), t.s.fetch)).status).toBe(200); + }); + + it("refuses a bad signature, an old timestamp and unconfigured servers; records nothing", async () => { + const t = await paying(); + const ev = subscribed(t); + expect((await deliver(t.env, ev, t.s.fetch, { secret: "whsec_wrong" })).status).toBe(400); + expect((await deliver(t.env, ev, t.s.fetch, { t: Math.floor(now().getTime() / 1000) - 600 })).status).toBe(400); + expect((await deliver(t.env, ev, t.s.fetch, { header: "" })).status).toBe(400); + expect(t.sql.rows("SELECT COUNT(*) AS n FROM billing_events")[0]!.n).toBe(0); + expect(t.plan().plan).toBe("free"); + const off = setup({ STRIPE_SECRET_KEY: "sk_test_fake" }); + expect((await deliver(off.env, ev, t.s.fetch)).status).toBe(503); + }); + + it("a Stripe failure while applying answers 500 and does not record the event, so Stripe's retry applies it", async () => { + const t = await paying(); + const ev = subscribed(t); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const r = await deliver(t.env, ev, async () => { throw new Error("offline"); }); + log.mockRestore(); + expect(r.status).toBe(500); + expect(t.sql.rows("SELECT COUNT(*) AS n FROM billing_events")[0]!.n).toBe(0); + expect((await deliver(t.env, ev, t.s.fetch)).status).toBe(200); + expect(t.plan().plan).toBe("pro"); + }); + + it("a Custom purchase is recorded once, when the subscription's latest invoice is paid, and the owner is notified with ids only", async () => { + const t = await paying("genclass_custom_monthly"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const sub = t.s.subs.get("sub_ada0000001")!; + // Checkout made the subscription; its first invoice is not paid yet: Custom is not bought yet + t.s.invoices.set("in_ada0000001", invoice("in_ada0000001", "cus_ada0000001", "sub_ada0000001", [["price_custom0001", 59900]], "open")); + sub.latest_invoice = "in_ada0000001"; + sub.status = "incomplete"; + expect((await deliver(t.env, stripeEvent("customer.subscription.created", sub), t.s.fetch)).status).toBe(200); + expect(t.plan().plan).toBe("free"); + expect(t.sql.rows("SELECT COUNT(*) AS n FROM custom_requests")[0]!.n).toBe(0); + // paid: Stripe moves the subscription to active (customer.subscription.updated, its status changed) + t.s.invoices.get("in_ada0000001")!.status = "paid"; + sub.status = "active"; + const paid = stripeEvent("customer.subscription.updated", sub, undefined, {}); + (paid.data as any).previous_attributes = { status: "incomplete" }; + expect((await deliver(t.env, paid, t.s.fetch)).status).toBe(200); + // and again (a renewal, a redelivered change): still one purchase, one notice + expect((await deliver(t.env, stripeEvent("customer.subscription.updated", sub), t.s.fetch)).status).toBe(200); + log.mockRestore(); + expect(t.plan().plan).toBe("custom"); + const rows = t.sql.rows("SELECT id, account_id, email, subscription_id, notified, mode FROM custom_requests"); + expect(rows).toEqual([{ id: rows[0]!.id, account_id: t.account.id, email: "ada@example.com", subscription_id: "sub_ada0000001", notified: now().toISOString(), mode: "live" }]); + expect(t.s.other).toHaveLength(1); + expect(t.s.other[0]!.url).toBe("https://hooks.example.com/notify"); + const notice = JSON.parse(t.s.other[0]!.body); + expect(notice).toMatchObject({ event: "custom_purchase", request_id: rows[0]!.id, account_id: t.account.id, subscription_id: "sub_ada0000001", mode: "live" }); + // the buyer's email stays in custom_requests; the chat channel gets ids only + expect(t.s.other[0]!.body).not.toContain("ada@example.com"); + expect(notice.text).toBe(`GenClass Custom plan bought: request ${rows[0]!.id}, account ${t.account.id}. The buyer's email is in custom_requests; write to them within 1 business day.`); + expect(notice.content).toBe(notice.text); + }); + + it("a switch from Pro to Custom counts as a purchase only when Stripe reports the Custom price paid", async () => { + const t = await paying(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const sub = t.s.subs.get("sub_ada0000001")!; + t.s.invoices.set("in_pro", invoice("in_pro", "cus_ada0000001", "sub_ada0000001", [["price_pro000001", 9900]])); + sub.latest_invoice = "in_pro"; + expect((await deliver(t.env, subscribed(t), t.s.fetch)).status).toBe(200); + // the portal changes the price and invoices the prorated switch at once; until it is paid nothing is recorded + sub.items.data[0].price = price("genclass_custom_monthly"); + t.s.invoices.set("in_switch", invoice("in_switch", "cus_ada0000001", "sub_ada0000001", [["price_pro000001", -9500], ["price_custom0001", 57500]], "open")); + sub.latest_invoice = "in_switch"; + expect((await deliver(t.env, stripeEvent("customer.subscription.updated", sub), t.s.fetch)).status).toBe(200); + expect(t.plan().plan).toBe("custom"); + expect(t.sql.rows("SELECT COUNT(*) AS n FROM custom_requests")[0]!.n).toBe(0); + expect(t.s.other).toHaveLength(0); + // a paid invoice with only Pro lines (the previous renewal) is not the Custom payment + sub.latest_invoice = "in_pro"; + expect((await deliver(t.env, stripeEvent("customer.subscription.updated", sub), t.s.fetch)).status).toBe(200); + expect(t.sql.rows("SELECT COUNT(*) AS n FROM custom_requests")[0]!.n).toBe(0); + // the switch paid: now it is a purchase + t.s.invoices.get("in_switch")!.status = "paid"; + sub.latest_invoice = "in_switch"; + expect(invoicePaysCustom(t.s.invoices.get("in_switch")!, sub)).toBe(true); + expect((await deliver(t.env, stripeEvent("customer.subscription.updated", sub), t.s.fetch)).status).toBe(200); + log.mockRestore(); + expect(t.sql.rows("SELECT subscription_id FROM custom_requests")).toEqual([{ subscription_id: "sub_ada0000001" }]); + expect(JSON.parse(t.s.other[0]!.body).event).toBe("custom_purchase"); + // the older API shape (line.price) is read too; a zero or credit line is not a payment + expect(invoicePaysCustom({ lines: { data: [{ amount: 59900, price: { id: "price_custom0001" } }] } }, sub)).toBe(true); + expect(invoicePaysCustom({ lines: { data: [{ amount: 0, price: { id: "price_custom0001" } }] } }, sub)).toBe(false); + // the latest invoice must be paid, expanded, and the subscription's own + const paidSwitch = t.s.invoices.get("in_switch")!; + expect(latestInvoicePaysCustom({ ...sub, latest_invoice: paidSwitch })).toBe(true); + expect(latestInvoicePaysCustom({ ...sub, latest_invoice: "in_switch" })).toBe(false); + expect(latestInvoicePaysCustom({ ...sub, latest_invoice: { ...paidSwitch, status: "open" } })).toBe(false); + expect(latestInvoicePaysCustom({ ...sub, latest_invoice: { ...paidSwitch, parent: { subscription_details: { subscription: "sub_other00001" } } } })).toBe(false); + }); + + it("Checkout events are not GenClass's to handle; invoice.paid is read for its subscription's id only, which is fetched again", async () => { + const t = await paying("genclass_custom_monthly"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const done = stripeEvent("checkout.session.completed", { id: "cs_test_000001", mode: "subscription", metadata: { genclass: "1", genclass_account: t.account.id, genclass_plan: "custom" }, customer: "cus_ada0000001", subscription: "sub_ada0000001", payment_status: "paid" }); + expect(await (await deliver(t.env, done, t.s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + expect(log).toHaveBeenCalledWith(`stripe webhook: checkout.session.completed ${done.id} ignored: event type not handled`); + expect(t.s.calls).toEqual([]); + // invoice.paid: the subscription is fetched as Stripe has it now, and that is what counts + const paid = invoice("in_ada0000001", "cus_ada0000001", "sub_ada0000001", [["price_custom0001", 59900]]); + t.s.invoices.set("in_ada0000001", paid); + t.s.subs.get("sub_ada0000001")!.latest_invoice = "in_ada0000001"; + const ev = stripeEvent("invoice.paid", { ...paid, customer_email: "ada@example.com", customer_name: "Ada Lovelace", customer_address: { line1: "1 Main St" } }); + expect(await (await deliver(t.env, ev, t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + log.mockRestore(); + expect(t.s.calls.map((c) => `${c.method} ${c.url.pathname}`)).toEqual(["GET /v1/subscriptions/sub_ada0000001", "GET /v1/subscriptions"]); + expect(t.plan().plan).toBe("custom"); + expect(t.sql.rows("SELECT subscription_id FROM custom_requests")).toEqual([{ subscription_id: "sub_ada0000001" }]); + // nothing of the buyer's from the invoice is stored or sent + const stored = JSON.stringify([t.sql.rows("SELECT * FROM accounts"), t.sql.rows("SELECT * FROM billing_events"), t.s.other.map((o) => o.body)]); + for (const x of ["Ada Lovelace", "1 Main St"]) expect(stored).not.toContain(x); + }); + + it("after a price change, subscribers on the replaced price keep their plan (a retired genclass_ key, the same product)", async () => { + const t = await paying(); + const sub = t.s.subs.get("sub_ada0000001")!; + sub.items.data[0].price = { ...price("genclass_pro_monthly_retired_price_old000001", { id: "price_old000001", plan: "pro" }), active: false }; + const r = await deliver(t.env, stripeEvent("customer.subscription.updated", sub), t.s.fetch); + expect(r.status).toBe(200); + expect(t.plan()).toMatchObject({ plan: "pro", subscription_status: "active" }); + }); + + it("two subscriptions for one account (two tabs paid): Custom is kept over Pro, Pro's payment refunded to its card and Pro cancelled", async () => { + const t = await paying(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + t.s.invoices.set("in_ada0000001", invoice("in_ada0000001", "cus_ada0000001", "sub_ada0000001", [["price_pro000001", 9900]])); + t.s.subs.get("sub_ada0000001")!.latest_invoice = "in_ada0000001"; + t.s.subs.set("sub_ada0000002", subscription("sub_ada0000002", t.account.id, "cus_ada0000001", "genclass_custom_monthly", "active", { created: 1_790_000_100 })); + // the older one's start is reported first: the newer (and higher) plan is still the one kept + const r = await deliver(t.env, subscribed(t), t.s.fetch); + log.mockRestore(); + expect(r.status).toBe(200); + // Pro's payment back to the card it came from, in full, then Pro cancelled with no proration (no balance credit) + const refund = t.s.calls.find((c) => c.method === "POST" && c.url.pathname === "/v1/refunds")!; + expect(Object.fromEntries(refund.form!)).toMatchObject({ payment_intent: paymentIntentFor("in_ada0000001"), amount: "9900", reason: "duplicate", "metadata[genclass_refund]": "duplicate:sub_ada0000001" }); + const cancel = t.s.calls.find((c) => c.method === "DELETE")!; + expect(t.s.calls.indexOf(refund)).toBeLessThan(t.s.calls.indexOf(cancel)); + expect(cancel.url.pathname).toBe("/v1/subscriptions/sub_ada0000001"); + expect(cancel.url.search).toBe(""); + expect(t.s.subs.get("sub_ada0000001")!.status).toBe("canceled"); + expect(t.plan()).toMatchObject({ plan: "custom", subscription_id: "sub_ada0000002" }); + const notice = JSON.parse(t.s.other.find((o) => o.body.includes("duplicate_subscription"))!.body); + expect(notice).toMatchObject({ event: "duplicate_subscription", account_id: t.account.id, kept: "sub_ada0000002", cancelled: ["sub_ada0000001"] }); + expect(notice.refunds).toEqual([{ subscription: "sub_ada0000001", kind: "duplicate", refund: "re_test000001", amount: 9900, currency: "usd" }]); + expect(notice.text).toContain("sub_ada0000001: refunded 99.00 USD to the card (re_test000001)"); + // the cancelled one's own events change nothing + const ended = await deliver(t.env, stripeEvent("customer.subscription.deleted", t.s.subs.get("sub_ada0000001")!), t.s.fetch); + expect(await ended.json()).toEqual({ received: true, result: "ignored" }); + expect(t.plan().plan).toBe("custom"); + // a plain update (no status change) does not look for duplicates + t.s.calls.length = 0; + expect((await deliver(t.env, stripeEvent("customer.subscription.updated", t.s.subs.get("sub_ada0000002")!), t.s.fetch)).status).toBe(200); + expect(t.s.calls.map((c) => `${c.method} ${c.url.pathname}`)).toEqual(["GET /v1/subscriptions/sub_ada0000002"]); + }); + + it("a subscription event for another Stripe customer is not applied to the account, whatever its metadata says", async () => { + const t = await paying(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + // a subscription naming this account in its metadata, on a customer the accounts table does not name + t.s.subs.set("sub_oth0000001", { ...subscription("sub_oth0000001", t.account.id, "cus_oth0000001", "genclass_custom_monthly") }); + const ev = stripeEvent("customer.subscription.created", t.s.subs.get("sub_oth0000001")!); + const r = await deliver(t.env, ev, t.s.fetch); + expect(log).toHaveBeenCalledWith(`stripe webhook: customer.subscription.created ${ev.id} ignored: not a GenClass customer`); + log.mockRestore(); + expect(await r.json()).toEqual({ received: true, result: "ignored" }); + expect(t.s.calls).toEqual([]); + expect(t.plan().plan).toBe("free"); + expect(t.sql.rows("SELECT stripe_customer_id FROM accounts")).toEqual([{ stripe_customer_id: "cus_ada0000001" }]); + expect(t.sql.rows("SELECT COUNT(*) AS n FROM billing_events")[0]!.n).toBe(0); + }); + + it("past_due keeps the plan 14 days from when GenClass first saw it, then Free, whatever the account's retry settings", async () => { + const t = await paying(); + const sub = t.s.subs.get("sub_ada0000001")!; + const state = () => t.sql.rows("SELECT plan, subscription_status, past_due_since FROM accounts")[0]!; + const DAY = 86_400_000; + expect(PAST_DUE_GRACE_DAYS).toBe(14); + expect((await deliver(t.env, subscribed(t), t.s.fetch)).status).toBe(200); + const first = now().toISOString(); + sub.status = "past_due"; + expect((await deliver(t.env, stripeEvent("customer.subscription.updated", sub), t.s.fetch)).status).toBe(200); + expect(state()).toEqual({ plan: "pro", subscription_status: "past_due", past_due_since: first }); + // a retry fails 10 days later: still Pro, and the grace still counts from the first time + advance(10 * DAY); + expect((await deliver(t.env, stripeEvent("customer.subscription.updated", sub), t.s.fetch)).status).toBe(200); + expect(state()).toEqual({ plan: "pro", subscription_status: "past_due", past_due_since: first }); + await planRetention(t.sql.api, now()); + expect(state().plan).toBe("pro"); + // day 15 with no event at all (the shared account may leave the subscription past due for good): the daily sweep + advance(5 * DAY); + await planRetention(t.sql.api, now()); + expect(state()).toEqual({ plan: "free", subscription_status: "past_due", past_due_since: first }); + // an event after the grace keeps it Free + expect((await deliver(t.env, stripeEvent("customer.subscription.updated", sub), t.s.fetch)).status).toBe(200); + expect(state().plan).toBe("free"); + // the page says why, and Upgrade asks for the open payment instead of a second subscription + const page = await (await call(t.env, "GET", "/account/billing", { cookie: t.cookie })).text(); + expect(page).toContain("The last payment for your paid plan did not go through, so it has ended."); + expect(page).toContain('id="portal"'); + const again = await call(t.env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie: t.cookie, fetch: t.s.fetch }); + expect(again.status).toBe(409); + expect(((await again.json()) as any).error).toBe("the last payment for your Pro plan did not go through; update your card in Manage billing to pay it, or email mehar@genclass.dev"); + expect(t.s.calls.some((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions")).toBe(false); + // paid after all: Pro again, and the next failure starts a new grace + sub.status = "active"; + expect((await deliver(t.env, stripeEvent("customer.subscription.updated", sub), t.s.fetch)).status).toBe(200); + expect(state()).toEqual({ plan: "pro", subscription_status: "active", past_due_since: null }); + sub.status = "past_due"; + expect((await deliver(t.env, stripeEvent("customer.subscription.updated", sub), t.s.fetch)).status).toBe(200); + expect(state().past_due_since).toBe(now().toISOString()); + const proPage = await (await call(t.env, "GET", "/account/billing", { cookie: t.cookie })).text(); + expect(proPage).toContain("if it is still unpaid 14 days after the failed payment, the account moves to the Free plan"); + advance(13 * DAY); + await planRetention(t.sql.api, now()); + expect(state().plan).toBe("pro"); + // unpaid (the account's retry setting may say "mark as unpaid") is Free at once, and the sweep catches it too + sub.status = "unpaid"; + expect((await deliver(t.env, stripeEvent("customer.subscription.updated", sub), t.s.fetch)).status).toBe(200); + expect(state()).toEqual({ plan: "free", subscription_status: "unpaid", past_due_since: null }); + t.sql.db.prepare("UPDATE accounts SET plan = 'pro'").run(); + await planRetention(t.sql.api, now()); + expect(state().plan).toBe("free"); + }); + + it("subscriptionState: past_due past its grace gives Free", () => { + const sub = subscription("sub_x00000001", "a".repeat(22), "cus_x00000001", "genclass_pro_monthly", "past_due"); + const at = new Date("2026-10-30T00:00:00Z"); + expect(subscriptionState(sub, { pastDueSince: "2026-10-16T00:00:00.000Z", now: at }).plan).toBe("pro"); + expect(subscriptionState(sub, { pastDueSince: "2026-10-15T23:59:59.000Z", now: at }).plan).toBe("free"); + expect(subscriptionState(sub).plan).toBe("pro"); + }); +}); + +// ------------------------------------------------------------------------------------------------- shared account + +describe("shared Stripe account (shared with another business): GenClass touches only its own objects", () => { + // the other business sells in the same Stripe account: its customers (one has the same email as a GenClass account), its + // orders, subscriptions and invoices. None of it may change a plan, be fetched from Stripe, or be stored or logged + // beyond an event's id and type. + const OTHER = "cus_other0000001"; + const otherCustomer = { id: OTHER, object: "customer", email: "ada@example.com", name: "Ada Lovelace", phone: "+14165550100", address: { line1: "1 Other Way", city: "Toronto" }, metadata: { other_id: "7781" } }; + const PRIVATE = [OTHER, "ada@example.com", "Ada Lovelace", "Other Way", "4165550100", "OS-1001", "sub_other0000001", "in_other0000001", "price_other0000001"]; + const otherSub = (status = "active") => ({ + id: "sub_other0000001", + object: "subscription", + customer: OTHER, + status, + created: 1_790_000_000, + metadata: { order: "OS-1001" }, + cancel_at_period_end: false, + items: { data: [{ id: "si_other0000001", current_period_end: 1_792_000_000, price: { id: "price_other0000001", object: "price", lookup_key: "other_tires_monthly", product: "prod_other0000001" } }] }, + }); + const otherInvoice = (sub: string | null) => ({ + id: "in_other0000001", + object: "invoice", + customer: OTHER, + customer_email: "ada@example.com", + customer_name: "Ada Lovelace", + customer_address: { line1: "1 Other Way", city: "Toronto" }, + parent: sub ? { subscription_details: { subscription: sub, metadata: { order: "OS-1001" } } } : null, + lines: { data: [{ amount: 18900, pricing: { price_details: { price: "price_other0000001" } } }] }, + }); + + async function shop(over: Record = {}) { + const t = setup({ ...LIVE, NOTIFY_WEBHOOK: "https://hooks.example.com/notify", ...over }); + const a = await signedIn(t.env); // ada@example.com, like the the other business customer + const s = fakeStripe(); + s.customers.set(OTHER, structuredClone(otherCustomer)); + s.subs.set("sub_other0000001", otherSub()); + const state = () => t.sql.rows("SELECT plan, stripe_customer_id, subscription_id, subscription_status, past_due_since FROM accounts"); + return { ...t, ...a, s, state }; + } + + /** the other business's webhook traffic, as GenClass's endpoint receives it for the whole account. */ + const otherEvents = (accountId: string) => [ + // a one-time order + stripeEvent("checkout.session.completed", { + id: "cs_live_other00001", + object: "checkout.session", + mode: "payment", + payment_status: "paid", + client_reference_id: "OS-1001", + customer: OTHER, + customer_details: { email: "ada@example.com", name: "Ada Lovelace", phone: "+14165550100", address: { line1: "1 Other Way" } }, + metadata: { order: "OS-1001" }, + }), + // a the other business subscription bought through Checkout + stripeEvent("checkout.session.completed", { id: "cs_live_other00002", object: "checkout.session", mode: "subscription", payment_status: "paid", customer: OTHER, subscription: "sub_other0000001", metadata: { order: "OS-1001" } }), + // a Checkout whose client_reference_id happens to look like a GenClass account id, without GenClass's metadata + stripeEvent("checkout.session.completed", { id: "cs_live_other00003", object: "checkout.session", mode: "subscription", payment_status: "paid", client_reference_id: accountId, customer: OTHER, subscription: "sub_other0000001", metadata: { order: "OS-1001" } }), + // ... or with an account_id of its own + stripeEvent("checkout.session.completed", { id: "cs_live_other00004", object: "checkout.session", mode: "subscription", payment_status: "paid", client_reference_id: accountId, customer: OTHER, subscription: "sub_other0000001", metadata: { account_id: accountId, genclass_account: accountId } }), + stripeEvent("customer.subscription.created", otherSub()), + stripeEvent("customer.subscription.updated", { ...otherSub("past_due"), metadata: { account_id: accountId } }), + stripeEvent("customer.subscription.deleted", otherSub("canceled")), + stripeEvent("invoice.paid", otherInvoice("sub_other0000001")), + stripeEvent("invoice.paid", otherInvoice(null)), + stripeEvent("invoice.payment_failed", otherInvoice("sub_other0000001")), + stripeEvent("customer.created", otherCustomer), + stripeEvent("charge.refunded", { id: "ch_other0000001", object: "charge", customer: OTHER, billing_details: { email: "ada@example.com" } }), + ]; + + it("Other Store orders, subscriptions and invoices change no plan, call Stripe for nothing, and leave nothing stored", async () => { + const t = await shop(); + // one GenClass account on Free, one paying for Pro + const b = await signedIn(t.env, "bob@example.com"); + setPlan(t.sql, b.account.id, "pro", { stripe_customer_id: "cus_bob0000001", subscription_id: "sub_bob0000001", subscription_status: "active", stripe_mode: "live", current_period_end: "2026-11-13T12:00:00.000Z" }); + const before = t.state(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const events = [...otherEvents(t.account.id), ...otherEvents(b.account.id)]; + for (const ev of events) { + const r = await deliver(t.env, ev, t.s.fetch); + expect(r.status, ev.type).toBe(200); + expect(await r.json(), ev.type).toEqual({ received: true, result: "ignored" }); + } + const lines = log.mock.calls.map((c) => c.join(" ")); + log.mockRestore(); + expect(t.state()).toEqual(before); + // nothing about them was fetched or sent anywhere + expect(t.s.calls).toEqual([]); + expect(t.s.other).toEqual([]); + // logged by type and id only + expect(lines).toHaveLength(events.length); + for (const [i, line] of lines.entries()) { + expect(line.startsWith(`stripe webhook: ${events[i]!.type} ${events[i]!.id} ignored: `)).toBe(true); + for (const x of PRIVATE) expect(line).not.toContain(x); + expect(line).not.toContain(t.account.id); + } + // stored: nothing, not even the event ids (a table of them would be a record of the other business's sales) + expect(t.sql.rows("SELECT * FROM billing_events")).toEqual([]); + const dump = JSON.stringify([t.sql.rows("SELECT * FROM custom_requests"), t.sql.rows("SELECT plan, stripe_customer_id, subscription_id, subscription_status FROM accounts")]); + for (const x of PRIVATE) expect(dump).not.toContain(x); + // the endpoint subscribes to no Checkout or customer events, and the invoice and charge events it takes were dropped + // above like the rest, with no Stripe call + for (const type of ["checkout.session.completed", "customer.created"]) expect(WEBHOOK_EVENTS).not.toContain(type); + }); + + it("a Checkout whose client_reference_id or metadata names a GenClass account changes nothing", async () => { + const t = await shop(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + // the account has no Stripe customer yet: the old code would have adopted the Checkout's customer + const ev = stripeEvent("checkout.session.completed", { id: "cs_live_lookalike1", mode: "subscription", payment_status: "paid", client_reference_id: t.account.id, customer: OTHER, subscription: "sub_other0000001" }); + expect(await (await deliver(t.env, ev, t.s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + expect(log).toHaveBeenCalledWith(`stripe webhook: checkout.session.completed ${ev.id} ignored: event type not handled`); + // GenClass's metadata naming an account, but not that account's GenClass customer + const forged = stripeEvent("checkout.session.completed", { id: "cs_live_lookalike2", mode: "subscription", payment_status: "paid", client_reference_id: t.account.id, metadata: { genclass: "1", genclass_account: t.account.id }, customer: OTHER, subscription: "sub_other0000001" }); + expect(await (await deliver(t.env, forged, t.s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + log.mockRestore(); + expect(t.state()).toEqual([{ plan: "free", stripe_customer_id: null, subscription_id: null, subscription_status: null, past_due_since: null }]); + expect(t.s.calls).toEqual([]); + }); + + it("a subscription of a GenClass customer counts only when it is provably GenClass's and its customer names the account", async () => { + const t = await shop(); + t.sql.db.prepare("UPDATE accounts SET stripe_customer_id = 'cus_ada0000001', stripe_mode = 'live' WHERE id = ?").run(t.account.id); + t.s.genclassCustomer("cus_ada0000001", t.account.id); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const send = async (sub: Record) => { + t.s.subs.set(String(sub.id), sub); + return ((await (await deliver(t.env, stripeEvent("customer.subscription.updated", sub), t.s.fetch)).json()) as any).result; + }; + const mine = (id: string, p: Record) => ({ ...subscription(id, t.account.id, "cus_ada0000001", null), items: { data: [{ id: `si_${id.slice(4)}`, current_period_end: 1_792_000_000, price: p }] } }); + // a the other business price on the GenClass customer + expect(await send(mine("sub_foreign00001", price("other_tires_monthly")))).toBe("ignored"); + // a genclass_ lookup key on a product without genclass_plan, and GenClass's product under another lookup key + expect(await send(mine("sub_foreign00002", price("genclass_pro_monthly", { plan: null })))).toBe("ignored"); + expect(await send(mine("sub_foreign00003", price("tires_pro_monthly", { plan: "pro" })))).toBe("ignored"); + // a GenClass price, but a subscription that names another account + expect(await send({ ...subscription("sub_other00001", "Z".repeat(22), "cus_ada0000001", "genclass_pro_monthly") })).toBe("ignored"); + expect(t.state()[0]).toMatchObject({ plan: "free", subscription_id: null }); + // a GenClass price, but the customer's metadata names another account (the row was tampered with, say) + t.s.customers.get("cus_ada0000001")!.metadata.genclass_account = "Z".repeat(22); + expect(await send(subscription("sub_ada0000009", t.account.id, "cus_ada0000001", "genclass_pro_monthly"))).toBe("ignored"); + t.s.customers.get("cus_ada0000001")!.metadata.genclass_account = t.account.id; + expect(t.state()[0]).toMatchObject({ plan: "free", subscription_id: null }); + // GenClass's: applied + expect(await send(subscription("sub_ada0000009", t.account.id, "cus_ada0000001", "genclass_pro_monthly"))).toBe("applied"); + expect(t.state()[0]).toMatchObject({ plan: "pro", subscription_id: "sub_ada0000009" }); + // a customer deleted in Stripe keeps no metadata: the account row (only ensureCustomer writes it) names it + t.s.customers.set("cus_ada0000001", { id: "cus_ada0000001", object: "customer", deleted: true }); + t.s.subs.get("sub_ada0000009")!.status = "canceled"; + expect(await send(t.s.subs.get("sub_ada0000009")!)).toBe("applied"); + log.mockRestore(); + expect(t.state()[0]).toMatchObject({ plan: "free", subscription_status: "canceled" }); + // only GenClass's own subscriptions were fetched, and never a the other business object + for (const c of t.s.calls) for (const x of PRIVATE) expect(c.url.toString()).not.toContain(x); + }); + + it("customers: GenClass makes its own, never looks one up by email, and never reads or changes the other business's customer", async () => { + const t = await shop(); + const r = await call(t.env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie: t.cookie, fetch: t.s.fetch }); + expect(r.status).toBe(200); + const customerCalls = t.s.calls.filter((c) => c.url.pathname.startsWith("/v1/customers")); + expect(customerCalls.map((c) => `${c.method} ${c.url.pathname}`)).toEqual(["POST /v1/customers"]); + const made = [...t.s.customers.values()].find((c) => c.id !== OTHER)!; + expect(made).toMatchObject({ email: "ada@example.com", description: "GenClass account", metadata: { genclass: "1", genclass_account: t.account.id } }); + expect(t.state()[0]!.stripe_customer_id).toBe(made.id); + expect(t.s.customers.get(OTHER)).toEqual(otherCustomer); + // Checkout is for that customer only (no customer_email, so Stripe makes no customer of its own) + const session = t.s.calls.find((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions")!; + expect(session.form!.get("customer")).toBe(made.id); + expect(session.form!.has("customer_email")).toBe(false); + expect(session.form!.has("customer_creation")).toBe(false); + // a Checkout page of that customer that GenClass did not make is never reused or closed + t.s.sessions.set("cs_test_manual01", { id: "cs_test_manual01", object: "checkout.session", status: "open", customer: made.id, metadata: {}, url: "https://checkout.stripe.com/c/pay/cs_test_manual01" }); + const second = await call(t.env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie: t.cookie, fetch: t.s.fetch }); + expect(((await second.json()) as any).url).not.toContain("cs_test_manual01"); + expect(t.s.sessions.get("cs_test_manual01")!.status).toBe("open"); + expect(t.s.calls.some((c) => c.url.pathname.includes("cs_test_manual01"))).toBe(false); + // no request at all names a the other business object + for (const c of t.s.calls) for (const x of PRIVATE.filter((p) => p !== "ada@example.com")) expect(`${c.url} ${c.form ?? ""}`).not.toContain(x); + }); + + it("checkout sells only a price on GenClass's product: a genclass_ lookup key on another product is refused", async () => { + const t = await shop(); + // someone moved GenClass's lookup key onto a the other business price + const s = fakeStripe({ answer: (u) => (u.pathname === "/v1/prices" ? new Response(JSON.stringify({ data: [price("genclass_pro_monthly", { id: "price_other0000001", plan: null })] }), { status: 200 }) : undefined) }); + const r = await call(t.env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie: t.cookie, fetch: s.fetch }); + expect(r.status).toBe(503); + expect(((await r.json()) as any).error).toBe( + "Stripe: the price with the lookup key genclass_pro_monthly is not on GenClass's Pro product (metadata genclass_plan=pro); run scripts/stripe-setup.mjs", + ); + expect(s.calls.some((c) => c.url.pathname === "/v1/checkout/sessions")).toBe(false); + }); + + it("deleting an account, or a second subscription, cancels GenClass subscriptions only", async () => { + const t = await shop(); + t.sql.db.prepare("UPDATE accounts SET stripe_customer_id = 'cus_ada0000001', stripe_mode = 'live' WHERE id = ?").run(t.account.id); + t.s.genclassCustomer("cus_ada0000001", t.account.id); + t.s.subs.set("sub_ada0000001", subscription("sub_ada0000001", t.account.id, "cus_ada0000001", "genclass_pro_monthly")); + // something else on the GenClass customer (added by hand in the dashboard, say): not provably GenClass's + t.s.subs.set("sub_hand0000001", { ...subscription("sub_hand0000001", t.account.id, "cus_ada0000001", null, "active", { created: 1_790_000_500 }), items: { data: [{ id: "si_hand0000001", price: price("other_tires_monthly") }] } }); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const started = stripeEvent("customer.subscription.created", t.s.subs.get("sub_ada0000001")!); + expect(await (await deliver(t.env, started, t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + log.mockRestore(); + // the newer one is not GenClass's: it is not counted as a second subscription, and not cancelled + expect(t.s.calls.some((c) => c.method === "DELETE")).toBe(false); + expect(t.state()[0]).toMatchObject({ plan: "pro", subscription_id: "sub_ada0000001" }); + expect((await call(t.env, "DELETE", "/api/account", { body: { confirm: "delete" }, cookie: t.cookie, fetch: t.s.fetch })).status).toBe(204); + expect(t.s.calls.filter((c) => c.method === "DELETE").map((c) => c.url.pathname)).toEqual(["/v1/subscriptions/sub_ada0000001"]); + expect(t.s.subs.get("sub_hand0000001")!.status).toBe("active"); + expect(t.s.subs.get("sub_other0000001")!.status).toBe("active"); + }); + + it("the other business billing on the GenClass customer: no portal session, no Checkout, no balance credit", async () => { + const t = await shop(); + setPlan(t.sql, t.account.id, "pro", { stripe_customer_id: "cus_ada0000001", subscription_id: "sub_ada0000001", subscription_status: "active", stripe_mode: "live" }); + t.s.genclassCustomer("cus_ada0000001", t.account.id); + t.s.subs.set("sub_ada0000001", subscription("sub_ada0000001", t.account.id, "cus_ada0000001", "genclass_pro_monthly")); + // the other business's store found the newest customer with ada@example.com (GenClass's) and put a tire subscription on it + t.s.subs.set("sub_other0000002", { ...otherSub(), id: "sub_other0000002", customer: "cus_ada0000001", created: 1_790_000_300 }); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const opened = () => t.s.calls.filter((c) => c.method === "POST" && /^\/v1\/(billing_portal\/sessions|checkout\/sessions)/.test(c.url.pathname)); + const portal = await call(t.env, "POST", "/api/billing/portal", { body: {}, cookie: t.cookie, fetch: t.s.fetch }); + expect(portal.status).toBe(409); + expect(((await portal.json()) as any).error).toBe( + "this account's Stripe billing record holds something that is not GenClass's, so GenClass does not open it; email mehar@genclass.dev and we will sort it out", + ); + for (const plan of ["pro", "custom"]) expect((await call(t.env, "POST", "/api/billing/checkout", { body: { plan }, cookie: t.cookie, fetch: t.s.fetch })).status).toBe(409); + expect(opened()).toEqual([]); + // the the other business subscription's own events: on a GenClass customer, but not fetched, not stored, no plan change + for (const type of ["customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted"]) { + const ev = stripeEvent(type, t.s.subs.get("sub_other0000002")!); + expect(await (await deliver(t.env, ev, t.s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + expect(log).toHaveBeenCalledWith(`stripe webhook: ${type} ${ev.id} ignored: not a GenClass subscription`); + } + expect(t.sql.rows("SELECT COUNT(*) AS n FROM billing_events")[0]!.n).toBe(0); + expect(t.state()[0]).toMatchObject({ plan: "pro", subscription_id: "sub_ada0000001" }); + // nothing about the the other business subscription was fetched beyond the customer's subscription list + expect(t.s.calls.some((c) => c.url.pathname.includes("sub_other") || c.url.pathname === "/v1/invoices")).toBe(false); + // the owner hears of it, with ids only + const notices = t.s.other.map((o) => JSON.parse(o.body)); + expect(notices.map((n) => n.event)).toEqual(["shared_customer", "shared_customer", "shared_customer"]); + expect(notices[0]).toMatchObject({ account_id: t.account.id, customer: "cus_ada0000001" }); + for (const o of t.s.other) for (const x of PRIVATE) expect(o.body).not.toContain(x); + + // only an invoice of the other business's (a one-off order invoiced to that customer): the portal would list it, so no portal + t.s.subs.delete("sub_other0000002"); + t.s.invoices.set("in_other0000001", { ...otherInvoice(null), customer: "cus_ada0000001" }); + expect((await call(t.env, "POST", "/api/billing/portal", { body: {}, cookie: t.cookie, fetch: t.s.fetch })).status).toBe(409); + // ... nor a switch, whose old plan's credit would go to that customer's balance + expect((await call(t.env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie: t.cookie, fetch: t.s.fetch })).status).toBe(409); + expect(opened()).toEqual([]); + // GenClass's own invoices are fine: with the the other business one gone, Manage billing opens + t.s.invoices.delete("in_other0000001"); + t.s.invoices.set("in_ada0000001", invoice("in_ada0000001", "cus_ada0000001", "sub_ada0000001", [["price_pro000001", 9900]])); + expect((await call(t.env, "POST", "/api/billing/portal", { body: {}, cookie: t.cookie, fetch: t.s.fetch })).status).toBe(200); + expect(opened()).toHaveLength(1); + + // a second GenClass subscription while a the other business one is on the customer: the duplicate is refunded to its card + // and cancelled with no balance credit (a balance credit would pay the other business's next invoice); Custom is kept + t.s.subs.set("sub_other0000002", { ...otherSub(), id: "sub_other0000002", customer: "cus_ada0000001", created: 1_790_000_300 }); + t.s.subs.set("sub_ada0000002", subscription("sub_ada0000002", t.account.id, "cus_ada0000001", "genclass_custom_monthly", "active", { created: 1_790_000_400 })); + t.s.calls.length = 0; + t.s.other.length = 0; + expect(await (await deliver(t.env, stripeEvent("customer.subscription.created", t.s.subs.get("sub_ada0000002")!), t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + log.mockRestore(); + const cancels = t.s.calls.filter((c) => c.method === "DELETE"); + expect(cancels.map((c) => c.url.pathname)).toEqual(["/v1/subscriptions/sub_ada0000001"]); + expect(cancels[0]!.url.search).toBe(""); + expect(t.s.subs.get("sub_other0000002")!.status).toBe("active"); + const dup = JSON.parse(t.s.other[0]!.body); + expect(dup).toMatchObject({ event: "duplicate_subscription", kept: "sub_ada0000002", cancelled: ["sub_ada0000001"] }); + expect(dup.refunds).toEqual([{ subscription: "sub_ada0000001", kind: "duplicate", refund: "re_test000001", amount: 9900, currency: "usd" }]); + expect(t.s.refunds.get("re_test000001")).toMatchObject({ payment_intent: paymentIntentFor("in_ada0000001"), amount: 9900 }); + expect(sharedCustomer([t.s.subs.get("sub_ada0000002")!])).toBe(false); + }); + + it("a stored customer that is not GenClass's (a row from older code): checkout, the portal and deletion leave it alone", async () => { + const t = await shop(); + // the other business's customer and subscription on record, as the code before customers were checked could have left + setPlan(t.sql, t.account.id, "pro", { stripe_customer_id: OTHER, subscription_id: "sub_other0000001", subscription_status: "active", stripe_mode: "live" }); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + expect((await call(t.env, "POST", "/api/billing/portal", { body: {}, cookie: t.cookie, fetch: t.s.fetch })).status).toBe(409); + expect((await call(t.env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie: t.cookie, fetch: t.s.fetch })).status).toBe(409); + // the customer is read to find out, and nothing else + expect(t.s.calls.map((c) => `${c.method} ${c.url.pathname}`)).toEqual([`GET /v1/customers/${OTHER}`, `GET /v1/customers/${OTHER}`]); + // deleting the account: the other business's subscription is not GenClass's to cancel, so a person sorts it out + const del = await call(t.env, "DELETE", "/api/account", { body: { confirm: "delete" }, cookie: t.cookie, fetch: t.s.fetch }); + log.mockRestore(); + expect(del.status).toBe(409); + expect(((await del.json()) as any).error).toBe("your paid plan could not be cancelled automatically, so the account was not deleted; email mehar@genclass.dev"); + expect(t.s.calls.some((c) => c.method !== "GET")).toBe(false); + expect(t.s.subs.get("sub_other0000001")!.status).toBe("active"); + expect(t.s.customers.get(OTHER)).toEqual(otherCustomer); + // nothing recorded as live: the account goes, and still nothing is cancelled + setPlan(t.sql, t.account.id, "free", { subscription_status: "canceled" }); + expect((await call(t.env, "DELETE", "/api/account", { body: { confirm: "delete" }, cookie: t.cookie, fetch: t.s.fetch })).status).toBe(204); + expect(t.s.calls.some((c) => c.method !== "GET")).toBe(false); + }); + + it("portal: the configuration STRIPE_PORTAL_CONFIGURATION names, fetched by id and checked; never listed, never the default", async () => { + const t = await shop(); + setPlan(t.sql, t.account.id, "pro", { stripe_customer_id: "cus_ada0000001", subscription_id: "sub_ada0000001", subscription_status: "active", stripe_mode: "live" }); + t.s.genclassCustomer("cus_ada0000001", t.account.id); + for (let i = 0; i < 2; i++) expect((await call(t.env, "POST", "/api/billing/portal", { body: {}, cookie: t.cookie, fetch: t.s.fetch })).status).toBe(200); + const sessions = t.s.calls.filter((c) => c.url.pathname === "/v1/billing_portal/sessions"); + expect(sessions.map((c) => c.form!.get("configuration"))).toEqual([GENCLASS_PORTAL, GENCLASS_PORTAL]); + // fetched once (cached), by its id: the other business's configurations are never listed or read + expect(t.s.calls.filter((c) => c.url.pathname.startsWith("/v1/billing_portal/configurations")).map((c) => `${c.method} ${c.url.pathname}`)).toEqual([ + `GET /v1/billing_portal/configurations/${GENCLASS_PORTAL}`, + ]); + + // the setting names a configuration that is not GenClass's: the account's default one (even carrying the + // metadata), the other business's other one, an inactive one, or none in this mode: 503, and no session + const refused = async (over: Record, edit?: (portal: Record[]) => void) => { + resetStripeCache(); + const u = await shop(over); + setPlan(u.sql, u.account.id, "pro", { stripe_customer_id: "cus_ada0000001", subscription_id: "sub_ada0000001", subscription_status: "active", stripe_mode: "live" }); + u.s.genclassCustomer("cus_ada0000001", u.account.id); + edit?.(u.s.portal); + const r = await call(u.env, "POST", "/api/billing/portal", { body: {}, cookie: u.cookie, fetch: u.s.fetch }); + expect(r.status).toBe(503); + expect(u.s.calls.some((c) => c.url.pathname === "/v1/billing_portal/sessions")).toBe(false); + expect(u.s.calls.some((c) => c.method === "GET" && c.url.pathname === "/v1/billing_portal/configurations")).toBe(false); + return ((await r.json()) as any).error as string; + }; + expect(await refused({ STRIPE_PORTAL_CONFIGURATION: "bpc_otherdefault1" }, (p) => (p[0]!.metadata = { genclass: "1" }))).toMatch( + /bpc_otherdefault1 \(STRIPE_PORTAL_CONFIGURATION\) is not GenClass's Billing Portal configuration/, + ); + expect(await refused({ STRIPE_PORTAL_CONFIGURATION: "bpc_other00001" })).toMatch(/is not GenClass's Billing Portal configuration/); + expect(await refused({}, (p) => (p[2]!.active = false))).toMatch(/is not GenClass's Billing Portal configuration/); + expect(await refused({ STRIPE_PORTAL_CONFIGURATION: "bpc_nosuchone01" })).toMatch(/Stripe has no Billing Portal configuration bpc_nosuchone01/); + // not set (the setup script has not run) + expect(await refused({ STRIPE_PORTAL_CONFIGURATION: undefined })).toBe("Stripe: GenClass's Billing Portal configuration is not set (STRIPE_PORTAL_CONFIGURATION); run scripts/stripe-setup.mjs"); + }); + + it("test mode: the same isolation (the other business's test-mode traffic is ignored the same way)", async () => { + const TESTING = { STRIPE_SECRET_KEY: "sk_test_fake", STRIPE_WEBHOOK_SECRET_TEST: TEST_SECRET, STRIPE_WEBHOOK_SECRET: undefined, STRIPE_TEST_ACCOUNTS: "ada@example.com" }; + const t = await shop(TESTING); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + for (const ev of otherEvents(t.account.id)) { + const r = await deliver(t.env, { ...ev, livemode: false }, t.s.fetch, { secret: TEST_SECRET }); + expect(await r.json(), ev.type).toEqual({ received: true, result: "ignored" }); + } + log.mockRestore(); + expect(t.s.calls).toEqual([]); + // a test checkout makes a GenClass customer too, and its session carries GenClass's metadata + expect((await call(t.env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie: t.cookie, fetch: t.s.fetch })).status).toBe(200); + expect(t.s.calls[0]!.form!.get("metadata[genclass_account]")).toBe(t.account.id); + expect(t.s.calls.at(-1)!.form!.get("metadata[genclass]")).toBe("1"); + expect(t.s.customers.get(OTHER)).toEqual(otherCustomer); + }); +}); + +// ------------------------------------------------------------------------------------------------- Managed Payments, switching + +describe("Managed Payments for Pro, flexible billing mode, switching between Pro and Custom by Checkout, refunds and notices", () => { + const sessionForm = (s: ReturnType) => Object.fromEntries(s.calls.filter((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions").at(-1)!.form!); + const calls = (s: ReturnType) => s.calls.map((c) => `${c.method} ${c.url.pathname}`); + const PRO_UNUSED = 409; // floor(9900 * 107200 / 2592000): 29 h 46 min of the 30-day period left at T0 + const CUSTOM_UNUSED = 2477; // floor(59900 * 107200 / 2592000) + + it("Managed Payments is off unless MANAGED_PAYMENTS is on, only ever for Pro; both plans are flexible", async () => { + expect(STRIPE_VERSION).toBe("2025-06-30.basil"); + // what Managed Payments refuses on a subscription Checkout Session (and what GenClass never sends anyway) + const refused = [ + "adaptive_pricing", "automatic_tax", "tax_id_collection", "subscription_data[default_tax_rates]", "payment_method_configuration", "payment_method_types", + "customer_update", "shipping_address_collection", "shipping_options", "subscription_data[application_fee_percent]", "subscription_data[on_behalf_of]", + "subscription_data[transfer_data]", "subscription_data[invoice_settings]", "invoice_creation", "customer_email", "customer_creation", + ]; + const buy = async (over: Record, plan: string) => { + resetStripeCache(); + const { env } = setup({ ...LIVE, ...over }); + const { cookie } = await signedIn(env); + const s = fakeStripe(); + const r = await call(env, "POST", "/api/billing/checkout", { body: { plan }, cookie, fetch: s.fetch }); + expect(r.status).toBe(200); + const page = await (await call(env, "GET", "/account/billing", { cookie })).text(); + return { form: sessionForm(s), page }; + }; + const mp = (f: Record) => Object.keys(f).some((k) => k.startsWith("managed_payments")); + const pro = await buy({ MANAGED_PAYMENTS: "on" }, "pro"); + expect(pro.form).toMatchObject({ mode: "subscription", customer: "cus_test0001", "line_items[0][price]": "price_pro000001", "managed_payments[enabled]": "true", "subscription_data[billing_mode][type]": "flexible" }); + for (const k of Object.keys(pro.form)) for (const r of refused) expect(k.startsWith(r), k).toBe(false); + expect(pro.page).toContain("Pro is sold through Link, Stripe's merchant of record service"); + const custom = await buy({ MANAGED_PAYMENTS: "on" }, "custom"); + expect(custom.form).toMatchObject({ "line_items[0][price]": "price_custom0001", "subscription_data[billing_mode][type]": "flexible" }); + expect(mp(custom.form)).toBe(false); + for (const over of [{}, { MANAGED_PAYMENTS: "off" }]) { + const off = await buy(over, "pro"); + expect(mp(off.form)).toBe(false); + expect(off.form["subscription_data[billing_mode][type]"]).toBe("flexible"); + expect(off.page).not.toContain("sold through Link"); + } + // the var: on (true, 1, yes) is on; unset and anything else is off; Custom is never eligible + const pro_ = PLANS.find((p) => p.id === "pro")!; + const custom_ = PLANS.find((p) => p.id === "custom")!; + const env = (v?: string) => ({ BUCKET: { async put() {} }, MANAGED_PAYMENTS: v }) as any; + for (const v of ["on", " ON ", "true", "1", "yes"]) expect(managedPayments(env(v), pro_), v).toBe(true); + for (const v of [undefined, "", "off", "OFF", "false", "0", "no", "maybe"]) expect(managedPayments(env(v), pro_), String(v)).toBe(false); + expect(managedPayments(env("on"), custom_)).toBe(false); + // plans.json: the tax codes the setup script sets, and which plan is eligible + expect([pro_.stripe_tax_code, pro_.managed_payments, custom_.stripe_tax_code, custom_.managed_payments]).toEqual(["txcd_10103001", true, "txcd_10203001", false]); + // paid means the latest invoice is paid or asks for nothing + expect(paymentCleared({ latest_invoice: { status: "paid" } })).toBe(true); + expect(paymentCleared({ latest_invoice: { status: "open", amount_due: 0 } })).toBe(true); + expect(paymentCleared({ latest_invoice: { status: "open", amount_due: 9900 } })).toBe(false); + expect(paymentCleared({ latest_invoice: "in_x00000001" })).toBe(false); + expect(paymentCleared({ latest_invoice: null })).toBe(false); + }); + + /** + * An account paying for `plan` with subscription `subId` (its latest invoice paid) on customer cus_sw00000001, and a + * fake Stripe that records the account's row at each cancellation; `answer` can refuse a request. + */ + async function switching(plan: "pro" | "custom", subId: string, o: { mp?: boolean; answer?: (u: URL, init: RequestInit) => Response | undefined } = {}) { + const t = setup({ ...LIVE, NOTIFY_WEBHOOK: "https://hooks.example.com/notify", ...(o.mp ? { MANAGED_PAYMENTS: "on" } : {}) }); + const a = await signedIn(t.env); + const atCancel: unknown[] = []; + const s = fakeStripe({ + answer: (u, init) => { + if (init.method === "DELETE") atCancel.push(t.sql.rows("SELECT plan, subscription_id FROM accounts")[0]); + return o.answer?.(u, init); + }, + }); + const line = (p: "pro" | "custom"): [string, number] => (p === "custom" ? ["price_custom0001", 59900] : ["price_pro000001", 9900]); + setPlan(t.sql, a.account.id, plan, { stripe_customer_id: "cus_sw00000001", subscription_id: subId, subscription_status: "active", stripe_mode: "live", current_period_end: "2026-11-13T12:00:00.000Z" }); + s.genclassCustomer("cus_sw00000001", a.account.id); + s.invoices.set(`in_${subId.slice(4)}`, invoice(`in_${subId.slice(4)}`, "cus_sw00000001", subId, [line(plan)])); + s.subs.set(subId, subscription(subId, a.account.id, "cus_sw00000001", `genclass_${plan}_monthly`, "active", { latest_invoice: `in_${subId.slice(4)}` })); + const row = () => t.sql.rows("SELECT plan, subscription_id, subscription_status FROM accounts")[0]!; + /** A subscription Stripe started for the account (by default the switch's, its first invoice paid). */ + const bought = (id: string, to: "pro" | "custom", b: { status?: string; paid?: boolean; switchFrom?: string | null; created?: number } = {}) => { + const status = b.status ?? "active"; + const inv = `in_${id.slice(4)}`; + s.invoices.set(inv, invoice(inv, "cus_sw00000001", id, [line(to)], (b.paid ?? status === "active") ? "paid" : "open")); + const from = b.switchFrom === undefined ? subId : b.switchFrom; + const metadata: Record = { genclass: "1", genclass_account: a.account.id, ...(from ? { genclass_switch_from: from } : {}) }; + const sub = subscription(id, a.account.id, "cus_sw00000001", `genclass_${to}_monthly`, status, { created: b.created ?? 1_790_000_500, metadata, latest_invoice: inv }); + s.subs.set(id, sub); + return sub; + }; + /** Stripe's word that the latest invoice of subscription `id` is paid. */ + const invoicePaid = (id: string) => stripeEvent("invoice.paid", s.invoices.get(`in_${id.slice(4)}`)!); + const notices = () => s.other.map((x) => JSON.parse(x.body)); + return { ...t, ...a, s, atCancel, row, bought, invoicePaid, notices }; + } + + it("Pro to Custom: the Switch button opens a Checkout; once Custom is paid it is on the account, Pro's unused time is refunded to its card, then Pro is cancelled", async () => { + const t = await switching("pro", "sub_pro0000001"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + // the billing page offers the switch on the plan, and Manage billing no longer does + const page = await (await call(t.env, "GET", "/account/billing", { cookie: t.cookie })).text(); + expect(page).toContain('data-plan="custom">Switch to Custom'); + expect(page).toContain("Switching between Pro and Custom opens Stripe's checkout for the new plan."); + expect(page).toContain("its unused days are refunded to the card or account that paid for it"); + expect(page).not.toContain("switch between Pro and Custom, or cancel"); + expect(page).toContain("

    Card, invoices and cancellation.

    "); + const r = await call(t.env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie: t.cookie, fetch: t.s.fetch }); + expect(r.status).toBe(200); + expect(await r.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000001", switch: true }); + // the customer and everything on it are checked, the invoices too + expect(calls(t.s)).toEqual(["GET /v1/customers/cus_sw00000001", "GET /v1/subscriptions", "GET /v1/invoices", "GET /v1/checkout/sessions", "GET /v1/prices", "POST /v1/checkout/sessions"]); + const form = sessionForm(t.s); + expect(form).toMatchObject({ + customer: "cus_sw00000001", + "line_items[0][price]": "price_custom0001", + "metadata[genclass_plan]": "custom", + "metadata[genclass_switch_from]": "sub_pro0000001", + "subscription_data[metadata][genclass_switch_from]": "sub_pro0000001", + "subscription_data[metadata][genclass_account]": t.account.id, + "subscription_data[billing_mode][type]": "flexible", + success_url: "https://genclass.dev/account/billing?done=1&to=custom", + }); + expect(Object.keys(form).some((k) => k.startsWith("managed_payments"))).toBe(false); + expect(t.s.calls.at(-1)!.headers["idempotency-key"]).toMatch(new RegExp(`^genclass-checkout-${t.account.id}-custom-\\d+-from-sub_pro0000001$`)); + expect(t.s.calls.some((c) => c.url.pathname.startsWith("/v1/billing_portal") || c.method === "DELETE")).toBe(false); + // a second click gets the same page + t.s.calls.length = 0; + expect(((await (await call(t.env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie: t.cookie, fetch: t.s.fetch })).json()) as any).url).toBe("https://checkout.stripe.com/c/pay/cs_test_000001"); + expect(t.s.calls.some((c) => c.method === "POST")).toBe(false); + + // back from Checkout before Stripe's word: still Pro, and a page that waits for Custom with no buttons + const back = await (await call(t.env, "GET", "/account/billing?done=1&to=custom", { cookie: t.cookie })).text(); + expect(back).toContain("This page changes to Custom as soon as Stripe confirms your payment"); + expect(back).toContain('id="donemsg"'); + expect(back).toContain('{"want":"custom"}'); + expect(back).not.toContain("data-plan="); + expect(t.row()).toEqual({ plan: "pro", subscription_id: "sub_pro0000001", subscription_status: "active" }); + + // Stripe started the Custom subscription and its first invoice is paid + const custom = t.bought("sub_cus0000001", "custom"); + t.s.calls.length = 0; + const ev = stripeEvent("customer.subscription.created", custom); + expect(await (await deliver(t.env, ev, t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + // Custom on the account first; then Pro's unused time back to the card that paid it; then Pro cancelled + expect(calls(t.s)).toEqual([ + "GET /v1/subscriptions/sub_cus0000001", + "GET /v1/subscriptions", + "GET /v1/invoices", + "GET /v1/invoice_payments", + "GET /v1/refunds", + "POST /v1/refunds", + "DELETE /v1/subscriptions/sub_pro0000001", + ]); + expect(t.atCancel).toEqual([{ plan: "custom", subscription_id: "sub_cus0000001" }]); + expect(t.s.calls[2]!.url.searchParams.get("subscription")).toBe("sub_pro0000001"); + expect(t.s.calls[2]!.url.searchParams.get("status")).toBe("paid"); + const refund = t.s.calls[5]!; + expect(Object.fromEntries(refund.form!)).toEqual({ + payment_intent: paymentIntentFor("in_pro0000001"), + amount: String(PRO_UNUSED), + reason: "requested_by_customer", + "metadata[genclass]": "1", + "metadata[genclass_account]": t.account.id, + "metadata[genclass_refund]": "switch:sub_pro0000001", + "metadata[genclass_invoice]": "in_pro0000001", + }); + expect(refund.headers["idempotency-key"]).toBe(`genclass-refund-switch:sub_pro0000001-in_pro0000001-${PRO_UNUSED}`); + // no proration, no final invoice: nothing goes to the customer's balance + expect(t.s.calls[6]!.url.search).toBe(""); + expect(t.s.subs.get("sub_pro0000001")!.status).toBe("canceled"); + expect(t.row()).toEqual({ plan: "custom", subscription_id: "sub_cus0000001", subscription_status: "active" }); + // the Custom purchase is recorded; the owner always hears of the switch, with the refund + expect(t.sql.rows("SELECT subscription_id FROM custom_requests")).toEqual([{ subscription_id: "sub_cus0000001" }]); + expect(t.notices().map((n) => n.event)).toEqual(["custom_purchase", "plan_switch"]); + const sw = t.notices()[1]; + expect(sw).toMatchObject({ account_id: t.account.id, kept: "sub_cus0000001", cancelled: ["sub_pro0000001"] }); + expect(sw.refunds).toEqual([{ subscription: "sub_pro0000001", kind: "switch", refund: "re_test000001", amount: PRO_UNUSED, currency: "usd" }]); + expect(sw.text).toBe( + `GenClass account ${t.account.id} switched plans. Kept sub_cus0000001; cancelled sub_pro0000001. sub_pro0000001: refunded 4.09 USD to the card (re_test000001). Stripe (Link support, for Pro) can also refund a payment within 60 days of it: a charge_refunded notice would show it.`, + ); + expect(log).toHaveBeenCalledWith(`stripe webhook: account ${t.account.id} switched plans; kept sub_cus0000001, cancelled sub_pro0000001`); + // Pro's own end, the same start again, and Custom's invoice.paid change nothing more + expect(await (await deliver(t.env, stripeEvent("customer.subscription.deleted", t.s.subs.get("sub_pro0000001")!), t.s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + expect(await (await deliver(t.env, ev, t.s.fetch)).json()).toEqual({ received: true, duplicate: true }); + expect(await (await deliver(t.env, t.invoicePaid("sub_cus0000001"), t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + expect(t.row().plan).toBe("custom"); + expect(t.atCancel).toHaveLength(1); + expect(t.s.refunds.size).toBe(1); + expect(await (await call(t.env, "GET", "/account/billing?done=1&to=custom", { cookie: t.cookie })).text()).toContain("You are on the Custom plan now."); + log.mockRestore(); + }); + + it("Custom to Pro, with MANAGED_PAYMENTS on: Pro's Checkout uses it; once Pro is paid, Custom's unused time is refunded and Custom cancelled", async () => { + const t = await switching("custom", "sub_cus0000001", { mp: true }); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const r = await call(t.env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie: t.cookie, fetch: t.s.fetch }); + expect(await r.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000001", switch: true }); + expect(sessionForm(t.s)).toMatchObject({ + "line_items[0][price]": "price_pro000001", + "managed_payments[enabled]": "true", + "metadata[genclass_switch_from]": "sub_cus0000001", + "subscription_data[metadata][genclass_switch_from]": "sub_cus0000001", + "subscription_data[billing_mode][type]": "flexible", + success_url: "https://genclass.dev/account/billing?done=1&to=pro", + }); + const pro = t.bought("sub_pro0000001", "pro"); + t.s.calls.length = 0; + // the subscription became active after its first payment: updated, with its status changed + const ev = stripeEvent("customer.subscription.updated", pro, undefined, {}); + (ev.data as any).previous_attributes = { status: "incomplete" }; + expect(await (await deliver(t.env, ev, t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + expect(t.atCancel).toEqual([{ plan: "pro", subscription_id: "sub_pro0000001" }]); + expect(t.s.subs.get("sub_cus0000001")!.status).toBe("canceled"); + expect([...t.s.refunds.values()]).toEqual([expect.objectContaining({ payment_intent: paymentIntentFor("in_cus0000001"), amount: CUSTOM_UNUSED, metadata: expect.objectContaining({ genclass_refund: "switch:sub_cus0000001" }) })]); + expect(t.row()).toEqual({ plan: "pro", subscription_id: "sub_pro0000001", subscription_status: "active" }); + expect(t.sql.rows("SELECT COUNT(*) AS n FROM custom_requests")[0]!.n).toBe(0); + expect(t.notices().map((n) => n.event)).toEqual(["plan_switch"]); + expect(t.notices()[0].text).toContain(`sub_cus0000001: refunded 24.77 USD to the card (re_test000001)`); + log.mockRestore(); + }); + + it("a first payment still processing (active, invoice open) grants nothing and cancels nothing; invoice.paid grants it and records Custom", async () => { + const t = await switching("pro", "sub_pro0000001"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + // the switch's Custom subscription is active, but its bank debit has not cleared + const custom = t.bought("sub_cus0000001", "custom", { paid: false }); + const created = stripeEvent("customer.subscription.created", custom); + expect(await (await deliver(t.env, created, t.s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + expect(log).toHaveBeenCalledWith(`stripe webhook: customer.subscription.created ${created.id} ignored: its first payment has not cleared yet`); + // Pro renews meanwhile: the Custom subscription is not counted yet, so nothing is cancelled + expect(await (await deliver(t.env, t.invoicePaid("sub_pro0000001"), t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + expect(t.s.calls.some((c) => c.method === "DELETE" || c.url.pathname === "/v1/refunds")).toBe(false); + expect(t.row()).toEqual({ plan: "pro", subscription_id: "sub_pro0000001", subscription_status: "active" }); + expect(t.sql.rows("SELECT COUNT(*) AS n FROM custom_requests")[0]!.n).toBe(0); + // and no third Checkout starts while both exist + expect((await call(t.env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie: t.cookie, fetch: t.s.fetch })).status).toBe(409); + // the debit clears: invoice.paid (read for its subscription id only) grants Custom, records it, settles Pro + t.s.invoices.set("in_cus0000001", invoice("in_cus0000001", "cus_sw00000001", "sub_cus0000001", [["price_custom0001", 59900]])); + expect(await (await deliver(t.env, t.invoicePaid("sub_cus0000001"), t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + expect(t.atCancel).toEqual([{ plan: "custom", subscription_id: "sub_cus0000001" }]); + expect(t.row()).toEqual({ plan: "custom", subscription_id: "sub_cus0000001", subscription_status: "active" }); + expect(t.sql.rows("SELECT subscription_id FROM custom_requests")).toEqual([{ subscription_id: "sub_cus0000001" }]); + expect(t.notices().map((n) => n.event)).toEqual(["custom_purchase", "plan_switch"]); + + // a first purchase the same way: Free until the payment clears + const u = await switching("pro", "sub_pro0000009"); + setPlan(u.sql, u.account.id, "free", { subscription_id: null, subscription_status: null }); + u.s.subs.delete("sub_pro0000009"); + const first = u.bought("sub_pro0000002", "pro", { paid: false, switchFrom: null }); + expect(await (await deliver(u.env, stripeEvent("customer.subscription.created", first), u.s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + expect(u.row()).toEqual({ plan: "free", subscription_id: null, subscription_status: null }); + u.s.invoices.set("in_pro0000002", invoice("in_pro0000002", "cus_sw00000001", "sub_pro0000002", [["price_pro000001", 9900]])); + expect(await (await deliver(u.env, u.invoicePaid("sub_pro0000002"), u.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + expect(u.row()).toEqual({ plan: "pro", subscription_id: "sub_pro0000002", subscription_status: "active" }); + // an invoice of another business's subscription on that customer is not even fetched + u.s.calls.length = 0; + const foreign = stripeEvent("invoice.paid", { ...invoice("in_other0000009", "cus_sw00000001", "sub_other0000009", [["price_other0000001", 18900]]), parent: { subscription_details: { subscription: "sub_other0000009", metadata: { order: "OS-1" } } } }); + expect(await (await deliver(u.env, foreign, u.s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + expect(u.s.calls).toEqual([]); + log.mockRestore(); + }); + + it("a switch never paid leaves the old plan; a failed cancellation is retried with the new plan on and the refund made once", async () => { + let failDelete = 1; + const t = await switching("pro", "sub_pro0000001", { + answer: (u, init) => (init.method === "DELETE" && failDelete-- > 0 ? new Response(JSON.stringify({ error: { message: "Stripe is having trouble" } }), { status: 500 }) : undefined), + }); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + // Checkout made the Custom subscription but its payment did not go through + const unpaid = t.bought("sub_cus0000001", "custom", { status: "incomplete" }); + expect(await (await deliver(t.env, stripeEvent("customer.subscription.created", unpaid), t.s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + unpaid.status = "incomplete_expired"; + const expired = stripeEvent("customer.subscription.updated", unpaid, undefined, {}); + (expired.data as any).previous_attributes = { status: "incomplete" }; + expect(await (await deliver(t.env, expired, t.s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + expect(t.s.calls.some((c) => c.method === "DELETE" || c.url.pathname === "/v1/refunds")).toBe(false); + expect(t.row()).toEqual({ plan: "pro", subscription_id: "sub_pro0000001", subscription_status: "active" }); + expect(t.sql.rows("SELECT COUNT(*) AS n FROM custom_requests")[0]!.n).toBe(0); + + // a second try is paid, but Stripe fails the cancellation of Pro once + const paid = t.bought("sub_cus0000002", "custom"); + const ev = stripeEvent("customer.subscription.created", paid); + expect((await deliver(t.env, ev, t.s.fetch)).status).toBe(500); + // the account is already on Custom; Pro still bills until Stripe's retry; the event is not marked handled + expect(t.atCancel).toEqual([{ plan: "custom", subscription_id: "sub_cus0000002" }]); + expect(t.row()).toEqual({ plan: "custom", subscription_id: "sub_cus0000002", subscription_status: "active" }); + expect(t.s.subs.get("sub_pro0000001")!.status).toBe("active"); + expect(t.sql.rows("SELECT COUNT(*) AS n FROM billing_events WHERE id = ?", ev.id)[0]!.n).toBe(0); + expect(t.s.refunds.size).toBe(1); + // while both bill, no third Checkout starts + expect((await call(t.env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie: t.cookie, fetch: t.s.fetch })).status).toBe(409); + // Stripe delivers the event again: the refund is found, not made twice; Pro is cancelled; Custom is recorded once + t.s.calls.length = 0; + expect(await (await deliver(t.env, ev, t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + expect(t.s.calls.filter((c) => c.method === "POST" && c.url.pathname === "/v1/refunds")).toEqual([]); + expect(t.s.refunds.size).toBe(1); + expect(t.s.subs.get("sub_pro0000001")!.status).toBe("canceled"); + expect(t.row()).toEqual({ plan: "custom", subscription_id: "sub_cus0000002", subscription_status: "active" }); + expect(t.sql.rows("SELECT subscription_id FROM custom_requests")).toEqual([{ subscription_id: "sub_cus0000002" }]); + expect(t.notices().filter((n) => n.event === "plan_switch")[0].refunds).toEqual([{ subscription: "sub_pro0000001", kind: "switch", refund: "re_test000001", amount: PRO_UNUSED, currency: "usd" }]); + log.mockRestore(); + }); + + it("a refused cancellation counts as done only when Stripe shows the subscription ended; a refused refund is reported, the cancellation still made", async () => { + // Stripe refuses the DELETE (a 400 that mentions cancelling) while the subscription is still active: not done + let mode: "active" | "ended" = "active"; + const t = await switching("pro", "sub_pro0000001", { + answer: (u, init) => { + if (init.method === "DELETE") { + if (mode === "ended") t.s.subs.get("sub_pro0000001")!.status = "canceled"; + return new Response(JSON.stringify({ error: { message: "This subscription cannot be canceled right now" } }), { status: 400 }); + } + if (init.method === "POST" && u.pathname === "/v1/refunds") return new Response(JSON.stringify({ error: { message: "Charge ch_x has already been refunded." } }), { status: 400 }); + return undefined; + }, + }); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const ev = stripeEvent("customer.subscription.created", t.bought("sub_cus0000001", "custom")); + expect((await deliver(t.env, ev, t.s.fetch)).status).toBe(500); + // it was read back after the refusal + expect(t.s.calls.slice(-2).map((c) => `${c.method} ${c.url.pathname}`)).toEqual(["DELETE /v1/subscriptions/sub_pro0000001", "GET /v1/subscriptions/sub_pro0000001"]); + expect(t.s.subs.get("sub_pro0000001")!.status).toBe("active"); + // the same refusal once it has ended (cancelled meanwhile): done + mode = "ended"; + expect(await (await deliver(t.env, ev, t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + // the refund Stripe refused (Link support refunded the payment first, say) is in the owner's notice + const sw = t.notices().find((n) => n.event === "plan_switch"); + expect(sw.refunds).toEqual([{ subscription: "sub_pro0000001", kind: "switch", refund: null, amount: 0, currency: "usd", note: "Stripe refused the refund (Charge ch_x has already been refunded.)" }]); + expect(sw.text).toContain("sub_pro0000001: not refunded, because Stripe refused the refund (Charge ch_x has already been refunded.); refund by hand if anything is due"); + log.mockRestore(); + }); + + it("an accidental second purchase (no switch): Custom is kept over Pro, the other one's latest payment refunded in full, the owner told", async () => { + const t = await switching("custom", "sub_cus0000001"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + // an old Pro page completed later: a newer Pro subscription with no switch in its metadata + const dup = t.bought("sub_pro0000002", "pro", { switchFrom: null }); + expect(await (await deliver(t.env, stripeEvent("customer.subscription.created", dup), t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + expect(t.row()).toEqual({ plan: "custom", subscription_id: "sub_cus0000001", subscription_status: "active" }); + expect(t.s.subs.get("sub_pro0000002")!.status).toBe("canceled"); + expect(t.s.subs.get("sub_cus0000001")!.status).toBe("active"); + expect([...t.s.refunds.values()]).toEqual([expect.objectContaining({ payment_intent: paymentIntentFor("in_pro0000002"), amount: 9900, reason: "duplicate" })]); + const n = t.notices().find((x) => x.event === "duplicate_subscription"); + expect(n).toMatchObject({ kept: "sub_cus0000001", cancelled: ["sub_pro0000002"] }); + expect(n.text).toContain("paid for a second subscription. Kept sub_cus0000001; cancelled sub_pro0000002. sub_pro0000002: refunded 99.00 USD to the card"); + log.mockRestore(); + }); + + it("an event about another, older subscription never overwrites the account's live plan unless it was chosen", async () => { + const t = await switching("pro", "sub_pro0000001"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + // an older Custom subscription Stripe still has (not settled yet): a plain update of it + t.s.subs.set("sub_old0000001", subscription("sub_old0000001", t.account.id, "cus_sw00000001", "genclass_custom_monthly", "active", { created: 1_789_000_000 })); + const ev = stripeEvent("customer.subscription.updated", t.s.subs.get("sub_old0000001")!); + expect(await (await deliver(t.env, ev, t.s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + expect(log).toHaveBeenCalledWith(`stripe webhook: customer.subscription.updated ${ev.id} ignored: not the account's current subscription`); + expect(t.row()).toEqual({ plan: "pro", subscription_id: "sub_pro0000001", subscription_status: "active" }); + // once the account's plan has ended, a live one may take its place (a purchase after a cancelled plan) + setPlan(t.sql, t.account.id, "free", { subscription_status: "canceled" }); + t.s.subs.get("sub_pro0000001")!.status = "canceled"; + expect(await (await deliver(t.env, stripeEvent("customer.subscription.updated", t.s.subs.get("sub_old0000001")!), t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + expect(t.row()).toEqual({ plan: "custom", subscription_id: "sub_old0000001", subscription_status: "active" }); + log.mockRestore(); + }); + + it("refunds and disputes of a GenClass customer's charges reach the owner with ids only; anyone else's are dropped", async () => { + const t = await switching("pro", "sub_pro0000001"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const PII = ["ada@example.com", "Ada Lovelace", "1 Main St"]; + const refunded = stripeEvent("charge.refunded", { id: "ch_sw00000001", object: "charge", customer: "cus_sw00000001", amount_refunded: PRO_UNUSED, currency: "usd", billing_details: { email: PII[0], name: PII[1], address: { line1: PII[2] } } }); + expect(await (await deliver(t.env, refunded, t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + t.s.charges.set("ch_sw00000002", { id: "ch_sw00000002", object: "charge", customer: "cus_sw00000001", billing_details: { email: PII[0] } }); + const disputed = stripeEvent("charge.dispute.created", { id: "dp_sw00000001", object: "dispute", charge: "ch_sw00000002", amount: 9900, currency: "usd", evidence: { customer_email_address: PII[0] } }); + expect(await (await deliver(t.env, disputed, t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + expect(t.notices()).toEqual([ + expect.objectContaining({ event: "charge_refunded", account_id: t.account.id, charge: "ch_sw00000001", amount: PRO_UNUSED, currency: "usd" }), + expect.objectContaining({ event: "charge_disputed", account_id: t.account.id, charge: "ch_sw00000002", dispute: "dp_sw00000001", amount: 9900 }), + ]); + expect(t.notices()[0].text).toContain("check that nothing is refunded twice"); + for (const o of t.s.other) for (const x of PII) expect(o.body).not.toContain(x); + expect(t.sql.rows("SELECT type FROM billing_events ORDER BY type")).toEqual([{ type: "charge.dispute.created" }, { type: "charge.refunded" }]); + // another customer's: no notice, nothing stored; a dispute's charge is read (it names no customer) and nothing else + t.s.calls.length = 0; + t.s.other.length = 0; + t.s.charges.set("ch_zzz0000001", { id: "ch_zzz0000001", object: "charge", customer: "cus_zzz0000001" }); + for (const ev of [ + stripeEvent("charge.refunded", { id: "ch_zzz0000002", object: "charge", customer: "cus_zzz0000001", amount_refunded: 100, currency: "cad" }), + stripeEvent("charge.dispute.created", { id: "dp_zzz0000001", object: "dispute", charge: "ch_zzz0000001", amount: 100, currency: "cad" }), + ]) expect(await (await deliver(t.env, ev, t.s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + expect(calls(t.s)).toEqual(["GET /v1/charges/ch_zzz0000001"]); + expect(t.s.other).toEqual([]); + expect(t.sql.rows("SELECT COUNT(*) AS n FROM billing_events")[0]!.n).toBe(2); + log.mockRestore(); + }); + + it("no switch from a paused plan or while one is going through; deleting the account closes its own Checkout pages and cancels both subscriptions", async () => { + const t = await switching("pro", "sub_pro0000001"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + t.s.subs.get("sub_pro0000001")!.status = "paused"; + const paused = await call(t.env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie: t.cookie, fetch: t.s.fetch }); + expect(paused.status).toBe(409); + expect(((await paused.json()) as any).error).toBe("your Pro plan is paused; email mehar@genclass.dev to change it"); + t.s.subs.get("sub_pro0000001")!.status = "active"; + // the switch paid, its webhook not here yet: Stripe has both + t.bought("sub_cus0000001", "custom"); + const busy = await call(t.env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie: t.cookie, fetch: t.s.fetch }); + expect(busy.status).toBe(409); + expect(((await busy.json()) as any).error).toBe("a plan change is already going through; reload this page in a minute, or email mehar@genclass.dev"); + expect(t.s.calls.some((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions")).toBe(false); + // an open Checkout page of the account's, one GenClass did not make, and something else on the customer + t.s.sessions.set("cs_test_own00001", { id: "cs_test_own00001", object: "checkout.session", status: "open", customer: "cus_sw00000001", metadata: { genclass: "1", genclass_account: t.account.id, genclass_plan: "pro" }, url: "https://checkout.stripe.com/c/pay/cs_test_own00001" }); + t.s.sessions.set("cs_test_manual01", { id: "cs_test_manual01", object: "checkout.session", status: "open", customer: "cus_sw00000001", metadata: {}, url: "https://checkout.stripe.com/c/pay/cs_test_manual01" }); + t.s.subs.set("sub_hand0000001", { ...subscription("sub_hand0000001", t.account.id, "cus_sw00000001", null), items: { data: [{ id: "si_hand0000001", price: price("other_tires_monthly") }] } }); + expect((await call(t.env, "DELETE", "/api/account", { body: { confirm: "delete" }, cookie: t.cookie, fetch: t.s.fetch })).status).toBe(204); + expect(t.s.sessions.get("cs_test_own00001")!.status).toBe("expired"); + expect(t.s.sessions.get("cs_test_manual01")!.status).toBe("open"); + expect(t.s.calls.filter((c) => c.method === "DELETE").map((c) => c.url.pathname).sort()).toEqual(["/v1/subscriptions/sub_cus0000001", "/v1/subscriptions/sub_pro0000001"]); + expect(t.s.subs.get("sub_hand0000001")!.status).toBe("active"); + log.mockRestore(); + }); + + it("the end of the recorded subscription reaches the account even after Stripe erased its customer's data", async () => { + const t = await switching("pro", "sub_pro0000001"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const sub = t.s.subs.get("sub_pro0000001")!; + // a buyer's data deletion request: Stripe cancels the subscription and erases the customer's data + t.s.customers.set("cus_sw00000001", { id: "cus_sw00000001", object: "customer", metadata: {} }); + // still live: not provably the account's, so not applied + expect(await (await deliver(t.env, stripeEvent("customer.subscription.updated", sub), t.s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + // a customer naming another account does not end it either + sub.status = "canceled"; + sub.ended_at = 1_791_000_000; + t.s.customers.set("cus_sw00000001", { id: "cus_sw00000001", object: "customer", metadata: { genclass_account: "Z".repeat(22) } }); + expect(await (await deliver(t.env, stripeEvent("customer.subscription.deleted", sub), t.s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + expect(t.row().plan).toBe("pro"); + // the recorded subscription ended and its customer names no account: the plan ends + t.s.customers.set("cus_sw00000001", { id: "cus_sw00000001", object: "customer", metadata: {} }); + expect(await (await deliver(t.env, stripeEvent("customer.subscription.deleted", sub), t.s.fetch)).json()).toEqual({ received: true, result: "applied" }); + expect(t.row()).toEqual({ plan: "free", subscription_id: "sub_pro0000001", subscription_status: "canceled" }); + log.mockRestore(); + }); + + it("the daily sweep still ends overdue plans when the rest of it fails", async () => { + const t = await switching("pro", "sub_pro0000001"); + const DAY = 86_400_000; + setPlan(t.sql, t.account.id, "pro", { subscription_status: "past_due", past_due_since: new Date(now().getTime() - 20 * DAY).toISOString() }); + const broken = { ...t.sql.api, batch: async () => { throw new Error("D1 is down"); } }; + await expect(planRetention(broken as any, now())).rejects.toThrow("D1 is down"); + expect(t.row().plan).toBe("free"); + }); +}); + +// ------------------------------------------------------------------------------------------------- limits + +describe("plans: the Free plan's limits (apps, dashboard ranges)", () => { + it("a fourth app on Free is created or claimed, and waits for a plan of its own (plans per project, version 4)", async () => { + const { env } = setup(LIVE); + const { cookie } = await signedIn(env); + for (const n of ["a", "b", "c"]) await createApp(env, cookie, n); + const r = await call(env, "POST", "/api/account/projects", { body: { name: "d" }, cookie }); + expect(r.status).toBe(201); + const anon = (await (await call(env, "POST", "/api/projects", { body: { name: "Old" }, origin: null })).json()) as any; + expect((await call(env, "POST", "/api/account/projects/claim", { body: { dashboard_url: anon.dashboardUrl }, cookie })).status).toBe(200); + }); + + it("without any Stripe key a fourth app is created all the same; GenClass's account-wide grant covers it", async () => { + const { env, sql } = setup(); + const { cookie, account } = await signedIn(env); + for (const n of ["a", "b", "c"]) await createApp(env, cookie, n); + expect((await call(env, "POST", "/api/account/projects", { body: { name: "d" }, cookie })).status).toBe(201); + setPlan(sql, account.id, "free", { comp_until: "9999-12-31T00:00:00.000Z" }); + for (const n of ["e", "f"]) await createApp(env, cookie, n); + }); + + it("ranges: Free has no dashboard (its stats answer 402); Pro and Custom reach a year; a longer ask gets the longest shown", async () => { + const free = PLANS[0]!; + const pro = PLANS[1]!; + expect(viewRanges(free)).toEqual(["24h", "7d"]); + expect(viewRanges(pro)).toEqual(ACCOUNT_RANGES); + expect(pickRange("30d", ["24h", "7d"])).toBe("7d"); + expect(pickRange("1y", ["24h", "7d", "30d"])).toBe("30d"); + expect(pickRange(null, ACCOUNT_RANGES)).toBe("7d"); + expect(pickRange("bogus", ["24h", "7d"])).toBe("7d"); + + const { env, sql } = setup(LIVE); + const { cookie, account } = await signedIn(env); + const app = await createApp(env, cookie); + const range = async (r: string) => ((await (await call(env, "GET", `/api/account/projects/${app.id}/stats?range=${r}`, { cookie })).json()) as any); + const locked = await call(env, "GET", `/api/account/projects/${app.id}/stats?range=30d`, { cookie }); + expect(locked.status).toBe(402); + expect(await locked.json()).toMatchObject({ ok: false, locked: true, upgrade_url: "https://genclass.dev/account/billing" }); + const page = await (await call(env, "GET", `/account/apps/${app.id}`, { cookie })).text(); + expect(page).toContain("This app's plan has no dashboard."); + expect(page).not.toContain('data-r="7d"'); + expect(page).not.toContain("/stats"); + setPlan(sql, account.id, "pro"); + const year = await range("1y"); + expect(year.range).toBe("1y"); + expect(year.series).toHaveLength(365); + const proPage = await (await call(env, "GET", `/account/apps/${app.id}`, { cookie })).text(); + expect(proPage).toContain(''); + expect(proPage).toContain('1 year'); + expect(proPage).not.toContain("The Free plan shows"); + }); +}); + +// ------------------------------------------------------------------------------------------------- pages + +describe("GET /account/billing", () => { + it("shows the plan, the plans from plans.json and Upgrade buttons only when paid plans are on sale", async () => { + const off = setup(); + const a = await signedIn(off.env); + const html = await (await call(off.env, "GET", "/account/billing", { cookie: a.cookie })).text(); + expect(html).toContain("

    Plan and billing

    "); + expect(html).toContain("Paid plans are not on sale yet."); + expect(html).toContain("$99 / month"); + expect(html).toContain("$599 / month"); + expect(html).toContain("Interaction analytics (coming next)"); + expect(html).toContain('Need self-hosting, SSO or invoices? Email mehar@genclass.dev.'); + // no footer of what is stored: that is in the privacy policy + expect(html).not.toContain('class="dfoot"'); + expect(html).not.toContain("data-plan="); + expect(html).not.toContain('id="portal"'); + + const on = setup(LIVE); + const b = await signedIn(on.env); + const page = await (await call(on.env, "GET", "/account/billing", { cookie: b.cookie })).text(); + expect(page).toContain('data-plan="pro">Upgrade to Pro'); + expect(page).toContain('data-plan="custom">Upgrade to Custom'); + expect(page).toContain("You are on the Free plan.

    "); + // no trial: GenClass has none + expect(page).not.toMatch(/id="trial"|free trial/); + const visible = page.replace(/||/gi, ""); + expect(visible).not.toMatch(/\u2014|!/); + + setPlan(on.sql, b.account.id, "pro", { stripe_customer_id: "cus_b000000001", subscription_id: "sub_b000000001", subscription_status: "active", current_period_end: "2026-11-13T12:00:00.000Z", cancel_at_period_end: 1 }); + const paid = await (await call(on.env, "GET", "/account/billing?done=1", { cookie: b.cookie })).text(); + expect(paid).toContain("until Switch to Custom'); + expect(paid).toContain('id="portal"'); + expect(paid).toContain("You are on the Pro plan now."); + + expect((await call(on.env, "GET", "/account/billing", {})).headers.get("location")).toBe("/login?next=%2Faccount%2Fbilling"); + // the sidebars link to it + expect(await (await call(on.env, "GET", "/account", { cookie: b.cookie })).text()).toContain(''); + const app = await createApp(on.env, b.cookie); + expect(await (await call(on.env, "GET", `/account/apps/${app.id}`, { cookie: b.cookie })).text()).toContain(''); + }); + + it("?plan from the pricing page survives sign-in and picks that plan's button; back from Checkout there are no buttons", async () => { + const { env, sql } = setup(LIVE); + expect((await call(env, "GET", "/account/billing?plan=custom", {})).headers.get("location")).toBe("/login?next=%2Faccount%2Fbilling%3Fplan%3Dcustom"); + expect((await call(env, "GET", "/account/billing?plan=enterprise", {})).headers.get("location")).toBe("/login?next=%2Faccount%2Fbilling"); + const a = await signedIn(env); + const html = await (await call(env, "GET", "/account/billing?plan=custom", { cookie: a.cookie })).text(); + expect(html).toContain('

    Custom

    '); + expect(html).toContain(''); + expect(html).toContain('data-plan="pro">Upgrade to Pro'); + // ?start=1 with a plan (the genclass command's plan step): the page is told to open that plan's checkout itself. + // It survives the sign-in, means nothing without a plan, and a plain visit never starts anything + expect(html).not.toContain('"go"'); + expect((await call(env, "GET", "/account/billing?plan=custom&start=1", {})).headers.get("location")).toBe("/login?next=%2Faccount%2Fbilling%3Fplan%3Dcustom%26start%3D1"); + const going = await (await call(env, "GET", "/account/billing?plan=custom&start=1", { cookie: a.cookie })).text(); + expect(going).toContain('"go":true'); + expect(going).toContain("if (pick && cfg.go) pick.click();"); + expect(await (await call(env, "GET", "/account/billing?start=1", { cookie: a.cookie })).text()).not.toContain('"go"'); + expect(await (await call(env, "GET", "/account/billing?plan=enterprise&start=1", { cookie: a.cookie })).text()).not.toContain('"go"'); + // waiting for Stripe after paying: the plan still reads Free, and no button can start a second purchase + const waiting = await (await call(env, "GET", "/account/billing?done=1&plan=pro", { cookie: a.cookie })).text(); + expect(waiting).toContain('id="donemsg"'); + expect(waiting).not.toContain("data-plan="); + setPlan(sql, a.account.id, "pro", { subscription_id: "sub_p000000001", subscription_status: "active", current_period_end: "2026-11-13T12:00:00.000Z" }); + // already on that plan: nothing to pick (the page script's [data-pick] selector is not a button) + const onPro = await (await call(env, "GET", "/account/billing?plan=pro", { cookie: a.cookie })).text(); + expect(onPro).not.toContain(" data-pick>"); + expect(onPro).not.toContain('class="plan pick"'); + }); + + it("GET /api/billing gives the browser its plan; a Custom page says who emails and when", async () => { + const { env, sql } = setup(LIVE); + const a = await signedIn(env); + expect(await (await call(env, "GET", "/api/billing", { cookie: a.cookie })).json()).toEqual({ + plan: "free", + subscription_status: null, + current_period_end: null, + cancel_at_period_end: false, + on_sale: true, + access: null, + // the fixture's plans set no seats: no limit + seats: null, + }); + setPlan(sql, a.account.id, "custom", { subscription_id: "sub_c000000001", subscription_status: "active", current_period_end: "2026-11-13T12:00:00.000Z" }); + const html = await (await call(env, "GET", "/account/billing", { cookie: a.cookie })).text(); + expect(html).toContain("emails ada@example.com within 1 business day of your purchase"); + expect((await call(env, "GET", "/api/billing", {})).status).toBe(401); + }); +}); + +// ------------------------------------------------------------------------------------------------- deletion + +describe("deleting an account with a paid plan", () => { + it("cancels the subscription with Stripe first; if Stripe fails, the account stays", async () => { + const { env, sql } = setup(LIVE); + const a = await signedIn(env); + const s = fakeStripe(); + s.genclassCustomer("cus_del0000001", a.account.id); + s.subs.set("sub_del0000001", subscription("sub_del0000001", a.account.id, "cus_del0000001", "genclass_pro_monthly")); + setPlan(sql, a.account.id, "pro", { stripe_customer_id: "cus_del0000001", subscription_id: "sub_del0000001", subscription_status: "active" }); + const failing = await call(env, "DELETE", "/api/account", { body: { confirm: "delete" }, cookie: a.cookie, fetch: async () => { throw new Error("offline"); } }); + expect(failing.status).toBe(502); + expect(sql.rows("SELECT COUNT(*) AS n FROM accounts")[0]!.n).toBe(1); + const del = await call(env, "DELETE", "/api/account", { body: { confirm: "delete" }, cookie: a.cookie, fetch: s.fetch }); + expect(del.status).toBe(204); + expect(s.calls.map((c) => `${c.method} ${c.url.pathname}`)).toEqual(["GET /v1/customers/cus_del0000001", "GET /v1/subscriptions", "GET /v1/checkout/sessions", "DELETE /v1/subscriptions/sub_del0000001"]); + expect(s.subs.get("sub_del0000001")!.status).toBe("canceled"); + expect(sql.rows("SELECT COUNT(*) AS n FROM accounts")[0]!.n).toBe(0); + }); + + it("cancels every subscription the customer has, also one GenClass never recorded (a second one, or a late webhook)", async () => { + const { env, sql } = setup(LIVE); + const a = await signedIn(env); + const s = fakeStripe(); + s.genclassCustomer("cus_two0000001", a.account.id); + s.subs.set("sub_rec0000001", subscription("sub_rec0000001", a.account.id, "cus_two0000001", "genclass_pro_monthly")); + s.subs.set("sub_unr0000001", subscription("sub_unr0000001", a.account.id, "cus_two0000001", "genclass_custom_monthly", "past_due")); + s.subs.set("sub_old0000001", subscription("sub_old0000001", a.account.id, "cus_two0000001", "genclass_pro_monthly", "canceled")); + s.subs.set("sub_bob0000001", subscription("sub_bob0000001", "B".repeat(22), "cus_bob0000001", "genclass_pro_monthly")); + setPlan(sql, a.account.id, "pro", { stripe_customer_id: "cus_two0000001", subscription_id: "sub_rec0000001", subscription_status: "active", stripe_mode: "live" }); + expect((await call(env, "DELETE", "/api/account", { body: { confirm: "delete" }, cookie: a.cookie, fetch: s.fetch })).status).toBe(204); + expect(s.calls.filter((c) => c.method === "DELETE").map((c) => c.url.pathname).sort()).toEqual(["/v1/subscriptions/sub_rec0000001", "/v1/subscriptions/sub_unr0000001"]); + expect(s.subs.get("sub_bob0000001")!.status).toBe("active"); + + // a payment whose webhook never arrived: no subscription recorded, but the customer is + const b = await signedIn(env, "late@example.com"); + s.genclassCustomer("cus_late000001", b.account.id, "late@example.com"); + s.subs.set("sub_late000001", subscription("sub_late000001", b.account.id, "cus_late000001", "genclass_pro_monthly")); + setPlan(sql, b.account.id, "free", { stripe_customer_id: "cus_late000001", stripe_mode: "live" }); + expect((await call(env, "DELETE", "/api/account", { body: { confirm: "delete" }, cookie: b.cookie, fetch: s.fetch })).status).toBe(204); + expect(s.subs.get("sub_late000001")!.status).toBe("canceled"); + }); + + it("never cancels a subscription by its recorded id alone: only what Stripe lists on the checked customer as GenClass's", async () => { + const { env, sql } = setup(LIVE); + const a = await signedIn(env); + const s = fakeStripe(); + s.genclassCustomer("cus_swp0000001", a.account.id); + // the recorded subscription had a the other business price swapped in (the other business's default portal, or by hand): it is + // not GenClass's any more, and deleting the GenClass account leaves it to its business + s.subs.set("sub_swp0000001", { ...subscription("sub_swp0000001", a.account.id, "cus_swp0000001", null), items: { data: [{ id: "si_swp0000001", price: price("other_tires_monthly") }] } }); + setPlan(sql, a.account.id, "pro", { stripe_customer_id: "cus_swp0000001", subscription_id: "sub_swp0000001", subscription_status: "active", stripe_mode: "live" }); + expect((await call(env, "DELETE", "/api/account", { body: { confirm: "delete" }, cookie: a.cookie, fetch: s.fetch })).status).toBe(204); + expect(s.calls.some((c) => c.method === "DELETE")).toBe(false); + expect(s.subs.get("sub_swp0000001")!.status).toBe("active"); + + // no customer on record (a row from older code), a live subscription recorded: cancelled only when it is + // GenClass's and its customer names the account + const b = await signedIn(env, "old@example.com"); + s.genclassCustomer("cus_old0000001", b.account.id, "old@example.com"); + s.subs.set("sub_old0000001", subscription("sub_old0000001", b.account.id, "cus_old0000001", "genclass_pro_monthly")); + setPlan(sql, b.account.id, "pro", { subscription_id: "sub_old0000001", subscription_status: "active", stripe_mode: "live" }); + expect((await call(env, "DELETE", "/api/account", { body: { confirm: "delete" }, cookie: b.cookie, fetch: s.fetch })).status).toBe(204); + expect(s.subs.get("sub_old0000001")!.status).toBe("canceled"); + const c = await signedIn(env, "other@example.com"); + setPlan(sql, c.account.id, "pro", { subscription_id: "sub_other0000009", subscription_status: "active", stripe_mode: "live" }); + s.subs.set("sub_other0000009", { ...subscription("sub_other0000009", "Z".repeat(22), "cus_other0000009", "genclass_pro_monthly"), metadata: {} }); + s.calls.length = 0; + expect((await call(env, "DELETE", "/api/account", { body: { confirm: "delete" }, cookie: c.cookie, fetch: s.fetch })).status).toBe(204); + expect(s.calls.map((x) => `${x.method} ${x.url.pathname}`)).toEqual(["GET /v1/subscriptions/sub_other0000009"]); + expect(s.subs.get("sub_other0000009")!.status).toBe("active"); + }); + + it("refuses when payments are switched off but a paid plan is recorded; test-mode records need no Stripe call", async () => { + const off = setup(); + const a = await signedIn(off.env); + setPlan(off.sql, a.account.id, "pro", { stripe_customer_id: "cus_off0000001", subscription_id: "sub_off0000001", subscription_status: "active", stripe_mode: "live" }); + const s = fakeStripe(); + const r = await call(off.env, "DELETE", "/api/account", { body: { confirm: "delete" }, cookie: a.cookie, fetch: s.fetch }); + expect(r.status).toBe(503); + expect(((await r.json()) as any).error).toMatch(/payments are switched off on this server, so the account was not deleted; email mehar@genclass.dev/); + expect(off.sql.rows("SELECT COUNT(*) AS n FROM accounts")[0]!.n).toBe(1); + // nothing live recorded (only a customer): deletion goes ahead + setPlan(off.sql, a.account.id, "free", { subscription_status: "canceled" }); + expect((await call(off.env, "DELETE", "/api/account", { body: { confirm: "delete" }, cookie: a.cookie, fetch: s.fetch })).status).toBe(204); + + const on = setup(LIVE); + const t = await signedIn(on.env); + setPlan(on.sql, t.account.id, "pro", { stripe_customer_id: "cus_tst0000001", subscription_id: "sub_tst0000001", subscription_status: "active", stripe_mode: "test" }); + expect((await call(on.env, "DELETE", "/api/account", { body: { confirm: "delete" }, cookie: t.cookie, fetch: s.fetch })).status).toBe(204); + expect(s.calls).toEqual([]); + }); +}); + +// ------------------------------------------------------------------------------------------------- Stripe modes + +describe("Stripe test and live mode", () => { + const TESTING = { STRIPE_SECRET_KEY: "sk_test_fake", STRIPE_WEBHOOK_SECRET_TEST: TEST_SECRET, STRIPE_TEST_ACCOUNTS: "Owner@Example.com, someid" }; + + it("the key's prefix says the mode; with a test key only the listed accounts can buy", () => { + const env = (k?: string, list?: string) => ({ BUCKET: { async put() {} }, STRIPE_SECRET_KEY: k, STRIPE_TEST_ACCOUNTS: list }); + expect(stripeMode(env("sk_live_x"))).toBe("live"); + expect(stripeMode(env("rk_live_x"))).toBe("live"); + expect(stripeMode(env("sk_test_x"))).toBe("test"); + expect(stripeMode(env("pk_live_x"))).toBeNull(); + expect(stripeMode(env())).toBeNull(); + const ada = { id: "someid", email: null }; + expect(canBuy(env("sk_live_x"), { id: "x", email: null })).toBe(true); + expect(canBuy(env("sk_test_x", "owner@example.com,someid"), ada)).toBe(true); + expect(canBuy(env("sk_test_x", "owner@example.com"), { id: "x", email: "OWNER@example.com" })).toBe(true); + expect(canBuy(env("sk_test_x", "owner@example.com"), { id: "x", email: "other@example.com" })).toBe(false); + expect(canBuy(env("sk_test_x"), ada)).toBe(false); + expect(canBuy(env(), ada)).toBe(false); + }); + + it("test key on the production worker: the owner can test-buy, nobody else sees buttons or limits", async () => { + const { env } = setup(TESTING); + const owner = await signedIn(env, "owner@example.com"); + const other = await signedIn(env, "visitor@example.com"); + const s = fakeStripe(); + expect((await call(env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie: owner.cookie, fetch: s.fetch })).status).toBe(200); + expect(s.calls[0]!.headers.authorization).toBe("Bearer sk_test_fake"); + const ownerPage = await (await call(env, "GET", "/account/billing", { cookie: owner.cookie })).text(); + expect(ownerPage).toContain("Stripe test mode."); + expect(ownerPage).toContain('data-plan="pro"'); + expect((await call(env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie: other.cookie, fetch: s.fetch })).status).toBe(503); + const page = await (await call(env, "GET", "/account/billing", { cookie: other.cookie })).text(); + expect(page).toContain("Paid plans are not on sale yet."); + expect(page).not.toContain("data-plan="); + expect(page).not.toContain("Stripe test mode"); + expect(((await (await call(env, "GET", "/api/billing", { cookie: other.cookie })).json()) as any).on_sale).toBe(false); + // a fourth app is created whatever the key (it waits for a plan of its own) + for (const n of ["a", "b", "c"]) await createApp(env, other.cookie, n); + expect((await call(env, "POST", "/api/account/projects", { body: { name: "d" }, cookie: other.cookie })).status).toBe(201); + }); + + it("the webhook takes the test secret with a test key, and ignores (200) the other mode's events after going live", async () => { + const t = setup({ ...LIVE, STRIPE_WEBHOOK_SECRET_TEST: TEST_SECRET }); + const a = await signedIn(t.env); + const s = fakeStripe(); + s.subs.set("sub_mix0000001", subscription("sub_mix0000001", a.account.id, "cus_mix0000001", "genclass_pro_monthly")); + s.genclassCustomer("cus_mix0000001", a.account.id); + t.sql.db.prepare("UPDATE accounts SET stripe_customer_id = 'cus_mix0000001' WHERE id = ?").run(a.account.id); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + // signed by the test endpoint while the key is live + const fromTest = await deliver(t.env, stripeEvent("customer.subscription.updated", s.subs.get("sub_mix0000001")!), s.fetch, { secret: TEST_SECRET }); + expect(await fromTest.json()).toEqual({ received: true, result: "ignored" }); + // signed with the live secret but a test-mode event + const testEvent = stripeEvent("customer.subscription.updated", s.subs.get("sub_mix0000001")!, undefined, { livemode: false }); + expect(await (await deliver(t.env, testEvent, s.fetch)).json()).toEqual({ received: true, result: "ignored" }); + expect(s.calls).toEqual([]); + expect(t.sql.rows("SELECT plan FROM accounts")).toEqual([{ plan: "free" }]); + // a live event applies + const liveEvent = stripeEvent("customer.subscription.updated", s.subs.get("sub_mix0000001")!, undefined, { livemode: true }); + expect(await (await deliver(t.env, liveEvent, s.fetch)).json()).toEqual({ received: true, result: "applied" }); + log.mockRestore(); + // neither secret: 400 + expect((await deliver(t.env, liveEvent, s.fetch, { secret: "whsec_nobody" })).status).toBe(400); + // with a test key the test secret is the one checked + const tm = setup(TESTING); + expect((await deliver(tm.env, stripeEvent("customer.created", { id: "cus_1" }), s.fetch, { secret: TEST_SECRET })).status).toBe(200); + expect((await deliver(tm.env, stripeEvent("customer.created", { id: "cus_1" }), s.fetch, { secret: WEBHOOK_SECRET })).status).toBe(400); + }); + + it("a plan bought in test mode does not carry over once the key is live, and that account can buy for real", async () => { + const { env, sql } = setup(LIVE); + const a = await signedIn(env); + setPlan(sql, a.account.id, "custom", { stripe_customer_id: "cus_tst0000009", subscription_id: "sub_tst0000009", subscription_status: "active", stripe_mode: "test" }); + expect(((await (await call(env, "GET", "/api/billing", { cookie: a.cookie })).json()) as any).plan).toBe("free"); + const app = await createApp(env, a.cookie); + expect((await call(env, "GET", `/api/account/projects/${app.id}/stats?range=30d`, { cookie: a.cookie })).status).toBe(402); + expect((await call(env, "GET", `/api/account/projects/${app.id}/events`, { cookie: a.cookie })).status).toBe(403); + // Upgrade: a new live customer replaces the test one (whose plan goes with it) + const s = fakeStripe(); + expect((await call(env, "POST", "/api/billing/checkout", { body: { plan: "pro" }, cookie: a.cookie, fetch: s.fetch })).status).toBe(200); + expect(s.calls.map((c) => `${c.method} ${c.url.pathname}`)).toEqual(["POST /v1/customers", "GET /v1/prices", "POST /v1/checkout/sessions"]); + expect(s.calls[0]!.headers["idempotency-key"]).toBe(`genclass-customer-${a.account.id}-after-cus_tst0000009`); + expect(sql.rows("SELECT plan, stripe_customer_id, stripe_mode, subscription_id FROM accounts")).toEqual([{ plan: "free", stripe_customer_id: "cus_test0001", stripe_mode: "live", subscription_id: null }]); + }); + + it("a test key never touches an account that pays in live mode", async () => { + const { env, sql } = setup({ ...TESTING, STRIPE_TEST_ACCOUNTS: "ada@example.com" }); + const a = await signedIn(env); + setPlan(sql, a.account.id, "pro", { stripe_customer_id: "cus_liv0000001", subscription_id: "sub_liv0000001", subscription_status: "active", stripe_mode: "live" }); + const s = fakeStripe(); + const r = await call(env, "POST", "/api/billing/checkout", { body: { plan: "custom" }, cookie: a.cookie, fetch: s.fetch }); + expect(r.status).toBe(409); + expect(((await r.json()) as any).error).toMatch(/pays in Stripe's live mode/); + expect(s.calls).toEqual([]); + // its live plan is untouched and still counts under the test key (live records always count) + expect(sql.rows("SELECT plan, stripe_customer_id FROM accounts")).toEqual([{ plan: "pro", stripe_customer_id: "cus_liv0000001" }]); + expect(((await (await call(env, "GET", "/api/billing", { cookie: a.cookie })).json()) as any).plan).toBe("pro"); + }); +}); + +// ------------------------------------------------------------------------------------------------- migration + +describe("migration 0004 on a database that already has accounts and apps", () => { + it("existing accounts start on Free with no Stripe ids; their apps and dashboards are untouched", async () => { + const { d1 } = await import("./d1.js"); + const sql = d1({ + before: "0004", + seed: (db) => { + db.prepare("INSERT INTO accounts (id, email, created) VALUES (?, ?, ?)").run("A".repeat(22), "old@example.com", "2026-10-12T00:00:00.000Z"); + db.prepare("INSERT INTO projects (id, token, secret_hash, name, created, account_id, private_link) VALUES (?, ?, ?, ?, ?, ?, 0)").run("0123456789abcdef", `gc_${"O".repeat(22)}`, "h", "Old app", "2026-10-12T00:00:00.000Z", "A".repeat(22)); + db.prepare("INSERT INTO daily (token, day, metric, key, count) VALUES (?, '2026-10-12', 'sessions', '', 3)").run(`gc_${"O".repeat(22)}`); + }, + }); + expect(sql.rows("SELECT plan, stripe_customer_id, subscription_id, subscription_status, current_period_end, cancel_at_period_end FROM accounts")).toEqual([ + { plan: "free", stripe_customer_id: null, subscription_id: null, subscription_status: null, current_period_end: null, cancel_at_period_end: 0 }, + ]); + expect(sql.rows("SELECT name, account_id FROM projects")).toEqual([{ name: "Old app", account_id: "A".repeat(22) }]); + expect(sql.rows("SELECT count FROM daily")).toEqual([{ count: 3 }]); + for (const t of ["billing_events", "custom_requests", "integrations", "forward_log", "event_batches", "export_keys"]) expect(sql.rows(`SELECT COUNT(*) AS n FROM ${t}`)[0]!.n).toBe(0); + }); +}); diff --git a/telemetry-worker/test/cli-account.test.ts b/telemetry-worker/test/cli-account.test.ts index 2470f579..99ca9424 100644 --- a/telemetry-worker/test/cli-account.test.ts +++ b/telemetry-worker/test/cli-account.test.ts @@ -112,14 +112,14 @@ describe("genclass command: GET /api/cli/account and POST /api/cli/team/invite", expect(await no.json()).toMatchObject({ ok: false, upgrade_url: "https://genclass.dev/account/billing" }); }); - it("Team: the price, the subscription's state, the apps paid for, any number of people, and an invite link that is shown once", async () => { + it("Team: the price, the subscription's state, the seats, and an invite link that is shown once", async () => { const { env, sql } = setup({ STRIPE_SECRET_KEY: "sk_live_fake" }); const me = await cliUser(env); team(sql, me.account.id, 3); const j = await json(get(env, "/api/cli/account", me.bearer)); - expect(j.plan).toMatchObject({ id: "team", name: "Team", price: "$15 / app / month" }); - // sold per app since 2026-11-12: the quantity paid for (3) is the apps; people have no limit - expect([j.access, j.limits.seats, j.limits.apps, j.limits.history_days]).toEqual(["paid", null, 3, 90]); + expect(j.plan).toMatchObject({ id: "team", name: "Team", price: "$15 / project / month" }); + // Team is per project since plans.json version 4: no seat count, the whole team is in + expect([j.access, j.limits.seats, j.limits.apps, j.limits.history_days]).toEqual(["paid", null, null, 90]); expect(j.includes).toEqual({ analytics: true, forwarding: true, export: true, private_repositories: true }); expect(j.subscription).toEqual({ status: "active", current_period_end: "2026-12-13T12:00:00.000Z", cancel_at_period_end: false }); expect(j.offer).toBeNull(); diff --git a/telemetry-worker/test/cli.test.ts b/telemetry-worker/test/cli.test.ts index afd3214c..c840a17f 100644 --- a/telemetry-worker/test/cli.test.ts +++ b/telemetry-worker/test/cli.test.ts @@ -67,12 +67,21 @@ describe("genclass command: GET /api/cli/overview (src/cli.ts)", () => { const teamPlan = PLANS.find((p) => p.id === "team")!; expect(offered.offer).toEqual({ plan: { id: "team", name: "Team" }, - price: "$15 / app / month", + price: "$15 / project / month", summary: teamPlan.summary, features: teamPlan.features.filter((f) => f.available && !/^Everything in /.test(f.text)).map((f) => f.text), - checkout_url: "https://genclass.dev/account/billing?plan=team&start=1", - free: { name: "Free", apps: 3, history_days: 7, suggestions_shown: 3 }, + // plans are per project: the one app on Free has its own plan page + checkout_url: `https://genclass.dev/account/billing?project=${app.id}&from=cli`, + free: { name: "Free", apps: 3, history_days: 7, suggestions_shown: 3, live_fixes: 100 }, }); + // each app says its plan, whether Free was chosen for it, and its plan page + expect(offered.projects[0]).toMatchObject({ plan: { id: "free", name: "Free", source: null }, needs_plan: false, plan_chosen: false, plan_url: `https://genclass.dev/account/billing?project=${app.id}` }); + sql.db.prepare("UPDATE projects SET plan_chosen = '2026-11-12T00:00:00.000Z' WHERE id = ?").run(app.id); + expect(((await (await call(selling, "GET", "/api/cli/overview", { bearer: me.bearer, origin: null })).json()) as any).projects[0].plan_chosen).toBe(true); + // the app on Team: nothing left to offer (the account has no other app on Free) + sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_id = 'sub_x', subscription_status = 'active' WHERE id = ?").run(app.id); + expect(((await (await call(selling, "GET", "/api/cli/overview", { bearer: me.bearer, origin: null })).json()) as any).offer).toBeNull(); + sql.db.prepare("UPDATE projects SET plan = NULL, stripe_mode = NULL, subscription_id = NULL, subscription_status = NULL WHERE id = ?").run(app.id); expect(offered.offer.features).toContain("Visitor analytics"); expect(offered.offer.features.join(" ")).not.toContain("Everything in Free"); // a test key sells only to the accounts on its list @@ -87,7 +96,10 @@ describe("genclass command: GET /api/cli/overview (src/cli.ts)", () => { const team = (await (await call({ ...env, OPENAI_API_KEY: KEY }, "GET", "/api/cli/overview", { bearer: me.bearer, origin: null })).json()) as any; expect(team.plan).toEqual({ id: "team", name: "Team" }); expect(team.suggestions).toEqual({ per_day: 30, shown: null, used_today: 0, model: true }); - // an account that has a plan (here GenClass's grant) is offered none, whatever the server sells + // an account whose apps all have a plan (here GenClass's grant, which is per project for a plan sold per project: + // migration 0016 copied it onto the apps that existed) is offered none, whatever the server sells + expect(((await (await call({ ...selling, OPENAI_API_KEY: KEY }, "GET", "/api/cli/overview", { bearer: me.bearer, origin: null })).json()) as any).offer.checkout_url).toBe(`https://genclass.dev/account/billing?project=${app.id}&from=cli`); + sql.db.prepare("UPDATE projects SET comp_until = ?, comp_plan = 'team' WHERE id = ?").run(FUTURE(), app.id); expect(((await (await call({ ...selling, OPENAI_API_KEY: KEY }, "GET", "/api/cli/overview", { bearer: me.bearer, origin: null })).json()) as any).offer).toBeNull(); expect(JSON.stringify(team)).not.toContain(KEY); }); diff --git a/telemetry-worker/test/fixtures/plans-v3-seats.json b/telemetry-worker/test/fixtures/plans-v3-seats.json new file mode 100644 index 00000000..08b26a53 --- /dev/null +++ b/telemetry-worker/test/fixtures/plans-v3-seats.json @@ -0,0 +1,80 @@ +{ + "_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 developer (per_seat true: the Stripe subscription's quantity is the number of seats, one for the account's owner and one for each member; 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); 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": 3, + "currency": "usd", + "contact": "mehar@genclass.dev", + "grant_plan": "enterprise", + "retired_plans": ["pro", "custom", "individual"], + "footer": "Questions about Team, Enterprise, your own classifier, self-hosting or invoices? Email mehar@genclass.dev.", + "plans": [ + { + "id": "free", + "name": "Free", + "summary": "Find, see and fix the problems. For one person.", + "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 }, + "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": "GitHub App on public repositories", "available": true }, + { "text": "Page check", "available": true }, + { "text": "Suggestions: your top 3 next steps", "available": true }, + { "text": "Self-host the collector", "available": true } + ] + }, + { + "id": "team", + "name": "Team", + "summary": "Everything a team needs, per developer.", + "price_usd_cents": { "monthly": 1500, "yearly": null }, + "stripe_lookup_keys": { "monthly": "genclass_team_monthly", "yearly": null }, + "stripe_tax_code": "txcd_10103001", + "managed_payments": false, + "per_seat": true, + "limits": { "apps": null, "history_days": 90, "seats": 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": "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 apps", "available": true }, + { "text": "Team members, one seat each", "available": true } + ] + }, + { + "id": "enterprise", + "name": "Enterprise", + "summary": "Your own classifier and our models in our cloud, on agreed terms.", + "price_usd_cents": { "monthly": null, "yearly": null }, + "stripe_lookup_keys": { "monthly": null, "yearly": null }, + "stripe_tax_code": null, + "managed_payments": false, + "per_seat": false, + "limits": { "apps": null, "history_days": 365, "seats": null }, + "suggestions": { "per_day": 100, "shown": null }, + "entitlements": { "dashboard": true, "forwarding": true, "export": true, "analytics": true, "custom_model": true, "github_private": true }, + "features": [ + { "text": "Everything in Team", "available": true }, + { "text": "Your own classifier, trained by us", "available": true }, + { "text": "Our models in our cloud", "available": true }, + { "text": "The coding-agent loop run for your team", "available": true }, + { "text": "1 year of history", "available": true }, + { "text": "Self-hosting, invoices and a contract", "available": true }, + { "text": "Priority support", "available": true } + ] + } + ] +} diff --git a/telemetry-worker/test/plans.test.ts b/telemetry-worker/test/plans.test.ts index 2bbbbd9e..5951b23e 100644 --- a/telemetry-worker/test/plans.test.ts +++ b/telemetry-worker/test/plans.test.ts @@ -3,10 +3,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import PLANS_JSON from "../../billing/plans.json"; import { resetTokenCache } from "../src/dashboard.js"; import { resetOwnerCache } from "../src/paid.js"; -import { accessOf, appsOf, byAgreement, cleanQuantity, FREE, GRANT_PLAN, ownedPrice, PAID_PLANS, PLANS, planByLookupKey, planForPrice, priceText, RETIRED_PLAN_IDS, seatsOf, soldByQuantity, unitOf, viewRanges } from "../src/plans.js"; +import { accessOf, byAgreement, cleanSeats, FREE, GRANT_PLAN, ownedPrice, PAID_PLANS, PLANS, planByLookupKey, planForPrice, priceText, RETIRED_PLAN_IDS, seatsOf, viewRanges } from "../src/plans.js"; import { isGenClassSubscription, resetStripeCache, retiredSubscription, subscriptionState } from "../src/stripe.js"; import { - advance, call, createApp, deliver, @@ -27,8 +26,7 @@ import { // The real plans (billing/plans.json, version 3, 2026-11-11), with no fixture: Free, Team and Enterprise. Free is what // finds, shows and fixes the problems (the dashboard's problems for one person, 3 apps, 7 days shown); Team is sold on -// Stripe Checkout per app since 2026-11-12 (US$15 an app a month; per developer before, test/team.test.ts keeps the -// seats on that plan file) and adds visitor analytics, forwarding, export, 90 days and +// Stripe Checkout per developer (US$15 a seat a month) and adds visitor analytics, forwarding, export, 90 days and // members (test/team.test.ts); Enterprise is agreed by email and given by GenClass's own grant, with the classifier a // person trains. There is no trial. Selling one paid plan that is not per seat (Individual, until 2026-10-22) is tested on // test/fixtures/plans-individual.json (one-paid tests); the machinery that two paid plans or a locked dashboard reach @@ -48,11 +46,11 @@ const text = async (r: Promise | Response) => (await r).text(); const visible = (html: string) => html.replace(/||/gi, ""); describe("billing/plans.json: the one source of plans", () => { - it("has Free, Team and Enterprise with the owner's prices, limits and entitlements (version 3, 2026-11-11)", () => { + it("has Free, Team and Enterprise with the owner's prices, limits and entitlements (version 4, 2026-11-12: per project)", () => { expect(PLANS.map((p) => p.id)).toEqual(["free", "team", "enterprise"]); const [free, team, enterprise] = PLANS; expect(free!.price_usd_cents).toEqual({ monthly: 0, yearly: null }); - // US$15 a developer a month (the owner, 2026-11-11) + // US$15 a project a month (the owner, 2026-11-12; a developer a month before) expect(team!.price_usd_cents).toEqual({ monthly: 1500, yearly: null }); expect(enterprise!.price_usd_cents).toEqual({ monthly: null, yearly: null }); expect(PLANS.map((p) => p.stripe_lookup_keys)).toEqual([ @@ -61,17 +59,15 @@ describe("billing/plans.json: the one source of plans", () => { { monthly: null, yearly: null }, ]); // Free: what finds, shows and fixes the problems, for one person; nothing else - expect(free!.limits).toEqual({ apps: 3, history_days: 7, seats: 1 }); + expect(free!.limits).toEqual({ apps: 3, history_days: 7, seats: 1, live_fixes_per_month: 100 }); expect(free!.entitlements).toEqual({ dashboard: true, forwarding: false, export: false, analytics: false, custom_model: false, github_private: false }); - // Team: sold per app (owner, 2026-11-12), any number of people, everything a team uses but the classifier + // Team: sold per project, everything a team uses but the classifier a person trains expect(PLANS.map((p) => p.per_seat)).toEqual([false, false, false]); - expect(PLANS.map((p) => p.per_app === true)).toEqual([false, true, false]); - expect(PLANS.map((p) => [soldByQuantity(p), unitOf(p)])).toEqual([[false, null], [true, "app"], [false, null]]); - expect(team!.features.map((x) => x.text)).toContain("Unlimited team members"); - expect(team!.limits).toEqual({ apps: null, history_days: 90, seats: null }); + expect(PLANS.map((p) => p.per_project === true)).toEqual([false, true, false]); + expect(team!.limits).toEqual({ apps: null, history_days: 90, seats: null, live_fixes_per_month: null }); expect(team!.entitlements).toEqual({ dashboard: true, forwarding: true, export: true, analytics: true, custom_model: false, github_private: true }); expect(team!.features[0]).toEqual({ text: "Everything in Free", available: true }); - expect(enterprise!.limits).toEqual({ apps: null, history_days: 365, seats: null }); + expect(enterprise!.limits).toEqual({ apps: null, history_days: 365, seats: null, live_fixes_per_month: null }); expect(enterprise!.entitlements).toEqual({ dashboard: true, forwarding: true, export: true, analytics: true, custom_model: true, github_private: true }); expect(enterprise!.features[0]).toEqual({ text: "Everything in Team", available: true }); // the classifier a person from GenClass trains is in Enterprise only, in the entitlements and in the feature lines @@ -91,7 +87,7 @@ describe("billing/plans.json: the one source of plans", () => { expect(GRANT_PLAN.id).toBe("enterprise"); expect(FREE.id).toBe("free"); expect(priceText(free!)).toBe("$0"); - expect(priceText(team!)).toBe("$15 / app / month"); + expect(priceText(team!)).toBe("$15 / project / month"); expect(priceText(enterprise!)).toBe("By agreement"); expect(planByLookupKey("genclass_team_monthly")?.id).toBe("team"); // Individual (until 2026-10-22) and the plans before it name no plan any more @@ -99,7 +95,7 @@ describe("billing/plans.json: the one source of plans", () => { expect(viewRanges(free!)).toEqual(["24h", "7d"]); expect(viewRanges(team!)).toEqual(["24h", "7d", "30d", "90d"]); expect(viewRanges(enterprise!)).toEqual(["24h", "7d", "30d", "90d", "1y"]); - expect((PLANS_JSON as any).version).toBe(3); + expect((PLANS_JSON as any).version).toBe(4); expect((PLANS_JSON as any).footer).toBe("Questions about Team, Enterprise, your own classifier, self-hosting or invoices? Email mehar@genclass.dev."); expect((PLANS_JSON as any).grant_plan).toBe("enterprise"); expect(PLANS_JSON).not.toHaveProperty("trial_days"); @@ -115,53 +111,45 @@ describe("billing/plans.json: the one source of plans", () => { const at = new Date("2026-10-19T12:00:00Z"); const later = "2026-12-31T00:00:00.000Z"; const row = (o: Record = {}) => ({ plan: "free", stripe_mode: null, comp_until: null, ...o }); - const none = { plan: FREE, source: null, seats: 1, apps: 3 }; + const none = { plan: FREE, source: null, seats: 1 }; expect(accessOf(null, null, at)).toEqual(none); expect(accessOf(row(), "live", at)).toEqual(none); - expect(accessOf(row({ comp_until: later }), "live", at)).toMatchObject({ source: "comp", plan: { id: "enterprise" }, seats: null, apps: null }); + expect(accessOf(row({ comp_until: later }), "live", at)).toMatchObject({ source: "comp", plan: { id: "enterprise" }, seats: null }); expect(accessOf(row({ comp_until: "2026-10-01T00:00:00.000Z" }), "live", at)).toEqual(none); // the plans that ended (Pro and Custom on 2026-10-19, Individual on 2026-10-22) count as Free expect(accessOf(row({ plan: "individual", stripe_mode: "live" }), "live", at)).toEqual(none); expect(accessOf(row({ plan: "individual", comp_until: later }), "live", at)).toMatchObject({ source: "comp", plan: { id: "enterprise" } }); expect(accessOf(row({ plan: "pro" }), "live", at)).toEqual(none); expect(accessOf(row({ plan: "custom", stripe_mode: "live" }), "live", at)).toEqual(none); - // Team, paid: the subscription's quantity (the seats column) is the apps, one when the row has none; no seat limit - expect(accessOf(row({ plan: "team", stripe_mode: "live", seats: 4 }), "live", at)).toMatchObject({ source: "paid", plan: { id: "team" }, seats: null, apps: 4 }); - expect(accessOf(row({ plan: "team", stripe_mode: "live" }), "live", at)).toMatchObject({ source: "paid", plan: { id: "team" }, seats: null, apps: 1 }); + // Team, paid (per project since version 4): no seat count, everyone on the team is in + expect(accessOf(row({ plan: "team", stripe_mode: "live", seats: 4 }), "live", at)).toMatchObject({ source: "paid", plan: { id: "team" }, seats: null }); + expect(accessOf(row({ plan: "team", stripe_mode: "live" }), "live", at)).toMatchObject({ source: "paid", plan: { id: "team" }, seats: null }); // bought in test mode: counts only under a test key expect(accessOf(row({ plan: "team", stripe_mode: "test", seats: 4 }), "live", at)).toEqual(none); - expect(accessOf(row({ plan: "team", stripe_mode: "test", seats: 4 }), "test", at)).toMatchObject({ source: "paid", apps: 4 }); - // a grant names its plan (comp_plan); unknown, empty or free: plans.json grant_plan. A grant has no seat or app count - expect(accessOf(row({ comp_until: later, comp_plan: "team" }), "live", at)).toMatchObject({ source: "comp", plan: { id: "team" }, seats: null, apps: null }); + expect(accessOf(row({ plan: "team", stripe_mode: "test", seats: 4 }), "test", at)).toMatchObject({ source: "paid", seats: null }); + // a grant names its plan (comp_plan); unknown, empty or free: plans.json grant_plan. A grant has no seat count + expect(accessOf(row({ comp_until: later, comp_plan: "team" }), "live", at)).toMatchObject({ source: "comp", plan: { id: "team" }, seats: null }); for (const comp_plan of [null, "", "free", "pro"]) expect(accessOf(row({ comp_until: later, comp_plan }), "live", at), String(comp_plan)).toMatchObject({ source: "comp", plan: { id: "enterprise" } }); - // both: the plan listed later applies (a Team grant does not change a paid Team's apps; an Enterprise grant wins) - expect(accessOf(row({ plan: "team", stripe_mode: "live", seats: 2, comp_until: later, comp_plan: "team" }), "live", at)).toMatchObject({ source: "paid", apps: 2 }); - expect(accessOf(row({ plan: "team", stripe_mode: "live", seats: 2, comp_until: later }), "live", at)).toMatchObject({ source: "comp", plan: { id: "enterprise" }, seats: null, apps: null }); + // both: the plan listed later applies (a Team grant does not lower a paid Team's seats; an Enterprise grant wins) + expect(accessOf(row({ plan: "team", stripe_mode: "live", seats: 2, comp_until: later, comp_plan: "team" }), "live", at)).toMatchObject({ source: "paid", seats: null }); + expect(accessOf(row({ plan: "team", stripe_mode: "live", seats: 2, comp_until: later }), "live", at)).toMatchObject({ source: "comp", plan: { id: "enterprise" }, seats: null }); // PLAN_GATES open (a self-hosted collector): everyone, an app without an account too, has the grant plan expect(accessOf(null, null, at, true)).toMatchObject({ source: "open", plan: { id: "enterprise" }, seats: null }); expect(accessOf(row(), null, at, true)).toMatchObject({ source: "open", plan: { id: "enterprise" } }); }); - it("quantity: 1 to 200; a paid per-app plan holds the apps paid for and any number of people; a per-seat plan the seats", () => { - for (const ok of [1, 2, 200]) expect(cleanQuantity(ok)).toBe(ok); - for (const bad of [0, -1, 201, 1.5, "3", null, undefined, NaN, Infinity]) expect(cleanQuantity(bad), String(bad)).toBeNull(); + it("seats: 1 to 200 whole seats; a paid per-seat plan holds what was paid for, any other plan its own limit", () => { + for (const ok of [1, 2, 200]) expect(cleanSeats(ok)).toBe(ok); + for (const bad of [0, -1, 201, 1.5, "3", null, undefined, NaN, Infinity]) expect(cleanSeats(bad), String(bad)).toBeNull(); const [free, team, enterprise] = PLANS; expect(seatsOf(free!, null, null)).toBe(1); - expect(appsOf(free!, null, null)).toBe(3); - // Team (per app): the apps paid for, at least 1; no seat limit; a grant has no app limit - expect(appsOf(team!, "paid", 5)).toBe(5); - expect(appsOf(team!, "paid", null)).toBe(1); - expect(appsOf(team!, "paid", 0)).toBe(1); - expect(appsOf(team!, "comp", 5)).toBeNull(); + // Team is per project since version 4: no seat count, paid or granted expect(seatsOf(team!, "paid", 5)).toBeNull(); + expect(seatsOf(team!, "comp", 5)).toBeNull(); + // a plan sold per seat (none today) holds what was paid for + expect(seatsOf({ ...team!, per_seat: true }, "paid", 5)).toBe(5); + expect(seatsOf({ ...team!, per_seat: true }, "paid", 0)).toBe(1); expect(seatsOf(enterprise!, "comp", null)).toBeNull(); - expect(appsOf(enterprise!, "comp", null)).toBeNull(); - // a per-seat plan (Team until 2026-11-12) holds the seats paid for, and its apps are its own limit - const perSeat = { ...team!, per_app: false, per_seat: true }; - expect(seatsOf(perSeat, "paid", 5)).toBe(5); - expect(seatsOf(perSeat, "paid", null)).toBe(1); - expect(seatsOf(perSeat, "comp", 5)).toBeNull(); - expect(appsOf(perSeat, "paid", 5)).toBeNull(); }); }); @@ -183,10 +171,12 @@ describe("the dashboard is free: every app opens it, with an account or without" const { cookie } = await signedIn(env); const apps = []; for (const n of ["a", "b", "c"]) apps.push(await createApp(env, cookie, n)); - // the fourth app: the plan's limit, with where the plans are (the CLI prints this sentence) + // a fourth app is created and waits for a plan of its own (plans per project, version 4: test/project-plans.test.ts) const fourth = await call(env, "POST", "/api/account/projects", { body: { name: "d" }, cookie }); - expect(fourth.status).toBe(409); - expect(await fourth.json()).toEqual({ ok: false, error: "the Free plan holds 3 apps; see the plans at https://genclass.dev/account/billing", upgrade_url: "https://genclass.dev/account/billing" }); + expect(fourth.status).toBe(201); + // Free chosen for it on its plan page first (until then the app's page opens that plan page) + expect((await call(env, "GET", `/account/apps/${apps[0]!.id}`, { cookie })).headers.get("location")).toBe(`/account/billing?project=${apps[0]!.id}`); + expect((await call(env, "POST", `/api/billing/projects/${apps[0]!.id}/free`, { cookie, body: { reason: "trying" } })).status).toBe(200); const page = await text(call(env, "GET", `/account/apps/${apps[0]!.id}`, { cookie })); expect(page).not.toContain("has no dashboard"); // the sections are there and say which plans have them; no forms @@ -273,9 +263,8 @@ describe("no trial", () => { // Team is on sale to everyone under a live key on_sale: true, access: null, - // Free holds one person and 3 apps + // Free holds one person seats: 1, - apps: 3, }); }); @@ -317,8 +306,8 @@ describe("no trial", () => { }); }); -describe("Team is the one plan sold on the site, per app; Enterprise by email", () => { - it("checkout sells Team and nothing else, and never calls Stripe for another plan", async () => { +describe("Team is the one plan sold on the site, per project; Enterprise by email", () => { + it("checkout sells Team and nothing else, for one project, and never calls Stripe for another plan", async () => { const { env } = setup(LIVE); const { cookie } = await signedIn(env); const s = fakeStripe(); @@ -327,118 +316,64 @@ describe("Team is the one plan sold on the site, per app; Enterprise by email", expect(r.status, plan).toBe(400); expect(((await r.json()) as any).error).toBe("choose a plan: team"); } - for (const quantity of [0, 201, 1.5, "3", null]) { - for (const key of ["quantity", "seats"]) { - const r = await call(env, "POST", "/api/billing/checkout", { body: { plan: "team", [key]: quantity }, cookie, fetch: s.fetch }); - expect(r.status, `${key} ${String(quantity)}`).toBe(400); - expect(((await r.json()) as any).error).toBe("the number of apps is a whole number from 1 to 200; for more email mehar@genclass.dev"); - } - } + const r = await call(env, "POST", "/api/billing/checkout", { body: { plan: "team", seats: 3 }, cookie, fetch: s.fetch }); + expect(r.status).toBe(400); + expect(((await r.json()) as any).error).toBe("the Team plan is bought for one project: send project_id"); expect(s.calls).toEqual([]); }); - it("a Team checkout: the apps are the quantity, the buyer can still change them on Stripe's page and add a promotion code; the account's own apps when none is sent", async () => { - for (const [body, seats, made] of [ - [{ plan: "team", quantity: 3 }, "3", 0], - [{ plan: "team", seats: 3 }, "3", 0], - [{ plan: "team" }, "1", 0], - [{ plan: "team" }, "2", 2], - ] as const) { - const { env } = setup(LIVE); - const { cookie, account } = await signedIn(env); - for (let i = 0; i < made; i++) await createApp(env, cookie, `App ${i}`); - const s = fakeStripe(); - const r = await call(env, "POST", "/api/billing/checkout", { body, cookie, fetch: s.fetch }); - expect(r.status).toBe(200); - expect(await r.json()).toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_000001" }); - const session = s.calls.find((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions")!; - expect(Object.fromEntries(session.form!)).toMatchObject({ - mode: "subscription", - client_reference_id: account.id, - "line_items[0][price]": "price_team000001", - "line_items[0][quantity]": seats, - "line_items[0][adjustable_quantity][enabled]": "true", - "line_items[0][adjustable_quantity][minimum]": "1", - "line_items[0][adjustable_quantity][maximum]": "200", - "metadata[genclass_plan]": "team", - "metadata[genclass_seats]": seats, - payment_method_collection: "always", - allow_promotion_codes: "true", - "subscription_data[billing_mode][type]": "flexible", - }); - // Team is sold on standard Checkout, never through Managed Payments - expect(session.form!.has("managed_payments[enabled]")).toBe(false); - } - }); - - it("the webhook writes the plan and the seats Stripe has now; fewer seats later, and none once the plan ends", async () => { + it("an account's own subscription from before plans were per project: the webhook still writes the account, and it covers every app", async () => { const t = setup(LIVE); const a = await signedIn(t.env); const s = fakeStripe(); t.sql.db.prepare("UPDATE accounts SET stripe_customer_id = 'cus_ada0000001', stripe_mode = 'live' WHERE id = ?").run(a.account.id); s.genclassCustomer("cus_ada0000001", a.account.id); - const sub = (quantity: number, status = "active") => - subscription("sub_ada0000001", a.account.id, "cus_ada0000001", "genclass_team_monthly", status, { - items: { data: [{ id: "si_ada0000001", current_period_end: 1_792_000_000, quantity, price: price("genclass_team_monthly") }] }, - }); - expect(subscriptionState(sub(4))).toMatchObject({ plan: "team", priced: "team", seats: 4 }); - // more than one account holds reads as the most; a subscription that is not per seat has no seats - expect(subscriptionState(sub(5000)).seats).toBe(200); - expect(subscriptionState(subscription("sub_x", "A".repeat(22), "cus_x", "genclass_individual_monthly")).seats).toBeNull(); + const app = await createApp(t.env, a.cookie, "Shop"); + const sub = (status = "active") => subscription("sub_ada0000001", a.account.id, "cus_ada0000001", "genclass_team_monthly", status, { items: { data: [{ id: "si_ada0000001", current_period_end: 1_792_000_000, quantity: 4, price: price("genclass_team_monthly") }] } }); + // Team is not sold per seat any more: a quantity is not a seat count + expect(subscriptionState(sub())).toMatchObject({ plan: "team", priced: "team", seats: null }); const log = vi.spyOn(console, "log").mockImplementation(() => {}); const apply = async (o: Record) => { s.subs.set("sub_ada0000001", o); expect(await (await deliver(t.env, stripeEvent("customer.subscription.updated", o), s.fetch)).json()).toEqual({ received: true, result: "applied" }); return t.sql.rows("SELECT plan, seats FROM accounts")[0]; }; - expect(await apply(sub(4))).toEqual({ plan: "team", seats: 4 }); - expect(await (await call(t.env, "GET", "/api/billing", { cookie: a.cookie })).json()).toMatchObject({ plan: "team", access: "paid", seats: null, apps: 4 }); - expect(await apply(sub(2))).toEqual({ plan: "team", seats: 2 }); - expect(await apply(sub(2, "canceled"))).toEqual({ plan: "free", seats: null }); + expect(await apply(sub())).toEqual({ plan: "team", seats: null }); + expect(await (await call(t.env, "GET", "/api/billing", { cookie: a.cookie })).json()).toMatchObject({ plan: "team", access: "paid", seats: null }); + expect(((await (await call(t.env, "GET", `/api/account/projects/${app.id}/stats?range=90d`, { cookie: a.cookie })).json()) as any).range).toBe("90d"); + expect(await apply(sub("canceled"))).toEqual({ plan: "free", seats: null }); log.mockRestore(); - expect(await (await call(t.env, "GET", "/api/billing", { cookie: a.cookie })).json()).toMatchObject({ plan: "free", access: null, seats: 1, apps: 3 }); }); - it("the billing page: three plans; Team with its price, an apps box and Upgrade for an account that can buy; Talk to us for Enterprise", async () => { + it("the billing page: three plans; Team per project with Choose a project; Talk to us for Enterprise", async () => { for (const keys of [{}, LIVE]) { const t = setup(keys); const a = await signedIn(t.env); + await createApp(t.env, a.cookie, "Shop"); const page = await text(call(t.env, "GET", "/account/billing", { cookie: a.cookie })); const live = "STRIPE_SECRET_KEY" in keys; expect(page).toContain(live ? "You are on the Free plan.

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

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

    $15 / app / month

    '); - expect(page).toContain('US dollars, billed monthly'); - expect(page).toContain("
  • Unlimited team members
  • "); - // the classifier a person trains is listed under Enterprise only + expect(page).toContain('

    $15 / project / month

    '); + expect(page).toContain("
  • Your whole team on the project
  • "); expect(page.match(/
  • Your own classifier/g)).toHaveLength(1); - if (live) { - expect(page).toContain('Apps '); - expect(page).toContain(''); - } else { - expect(page).not.toContain("data-plan="); - expect(page).not.toContain('id="seats-team"'); - } - expect(page).not.toMatch(/Individual|\$29|Payments are handled by Stripe/); + if (live) expect(page).toContain('Choose a project'); + else expect(page).not.toContain("Choose a project"); + expect(page).not.toContain('id="seats-team"'); + expect(page).toContain("ShopFree"); expect(page).toContain('Talk to us'); expect(page).toContain('

    By agreement

    '); - expect(page).toContain('Questions about Team, Enterprise, your own classifier, self-hosting or invoices? Email mehar@genclass.dev.'); - // the Team section says what Free is: one person, members with Team - expect(page).toContain('1 of 1 seat used'); - expect(page).toContain("The Free plan is for one person. Members come with Team: as many as you invite, and they open the dashboards of your apps with their own sign-in."); expect(visible(page)).not.toMatch(/—|!/); } }); - it("a paid Team account: the status line says its apps and the monthly total; a server with PLAN_GATES open says it sells nothing", async () => { + it("a paid Team account (an old account subscription): the status line says the price; a server with PLAN_GATES open says it sells nothing", async () => { const t = setup(LIVE); const a = await signedIn(t.env); setPlan(t.sql, a.account.id, "team", { stripe_customer_id: "cus_b000000001", stripe_mode: "live", seats: 3, subscription_id: "sub_b000000001", subscription_status: "active", current_period_end: "2026-11-13T12:00:00.000Z" }); const page = await text(call(t.env, "GET", "/account/billing", { cookie: a.cookie })); - expect(page).toContain('You are on the Team plan for 3 apps, $15 / app / month ($45 a month). It renews on . Add or remove apps in Manage billing.'); + expect(page).toContain('You are on the Team plan, $15 / project / month. It renews on .'); expect(page).toContain('Your plan'); - expect(page).toContain('1 person'); - expect(page).toContain('

    Card, invoices, apps and cancellation.

    '); const open = setup({ PLAN_GATES: "open" }); const b = await signedIn(open.env); const self = await text(call(open.env, "GET", "/account/billing", { cookie: b.cookie })); @@ -447,42 +382,6 @@ describe("Team is the one plan sold on the site, per app; Enterprise by email", expect(self).not.toContain("data-plan="); }); - it("per app: no more apps than paid for; with fewer apps paid than held, only the first apps made keep Team", async () => { - const t = setup(LIVE); - const a = await signedIn(t.env); - const paid = (apps: number) => - setPlan(t.sql, a.account.id, "team", { stripe_customer_id: "cus_p000000001", stripe_mode: "live", seats: apps, subscription_id: "sub_p000000001", subscription_status: "active", current_period_end: "2026-11-13T12:00:00.000Z" }); - paid(2); - const first = await createApp(t.env, a.cookie, "First"); - advance(1000); - const second = await createApp(t.env, a.cookie, "Second"); - advance(1000); - // the third is past the 2 apps paid for: refused, with where to add one - const r = await call(t.env, "POST", "/api/account/projects", { body: { name: "Third" }, cookie: a.cookie }); - expect(r.status).toBe(409); - expect(await r.json()).toEqual({ - ok: false, - error: "your Team plan pays for 2 apps; add apps in Manage billing at https://genclass.dev/account/billing", - upgrade_url: "https://genclass.dev/account/billing", - }); - const forwarding = async (id: string) => ((await (await call(t.env, "GET", `/api/account/projects/${id}/integrations`, { cookie: a.cookie })).json()) as any).forwarding; - expect([await forwarding(first.id), await forwarding(second.id)]).toEqual([true, true]); - // one app paid for now (lowered in Manage billing): the app made first keeps Team, the other has Free's features - paid(1); - resetOwnerCache(); - expect([await forwarding(first.id), await forwarding(second.id)]).toEqual([true, false]); - const exp = await call(t.env, "GET", `/api/account/projects/${second.id}/events`, { cookie: a.cookie }); - expect(exp.status).toBe(403); - expect(await text(call(t.env, "GET", "/account/billing", { cookie: a.cookie }))).toContain( - "You are on the Team plan for 1 app, $15 / app / month ($15 a month).", - ); - expect(await text(call(t.env, "GET", "/account/billing", { cookie: a.cookie }))).toContain("You have 2 apps: the one made last has the Free plan's features until you add apps."); - // a grant of Team has no app limit - setPlan(t.sql, a.account.id, "free", { stripe_customer_id: null, subscription_id: null, subscription_status: null, current_period_end: null, seats: null, comp_until: "9999-12-31T00:00:00.000Z", comp_plan: "team" }); - expect([await forwarding(first.id), await forwarding(second.id)]).toEqual([true, true]); - expect((await call(t.env, "POST", "/api/account/projects", { body: { name: "Third" }, cookie: a.cookie })).status).toBe(201); - }); - it("an account still stored on Individual reads as Free; GenClass's grant gives Enterprise", async () => { const t = setup(LIVE); const b = await signedIn(t.env); diff --git a/telemetry-worker/test/project-plans.test.ts b/telemetry-worker/test/project-plans.test.ts new file mode 100644 index 00000000..b74d95f7 --- /dev/null +++ b/telemetry-worker/test/project-plans.test.ts @@ -0,0 +1,354 @@ +// Plans per project (billing/plans.json version 4, the owner's decision of 2026-11-12): Team is $15 a month for one +// project, everyone on the account's team included; Free covers an account's first 3 projects and 100 problems fixed +// live a month per project, after which the runtime is told to observe only. Checkout, the webhook, the pages, the +// lock on a fourth free project, the live-fix limit at ingest, and what a project that is paid for cannot lose. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resetTokenCache } from "../src/dashboard.js"; +import { resetOwnerCache } from "../src/paid.js"; +import { accountWide, FREE, priceText, PLANS } from "../src/plans.js"; +import { FREE_REASONS } from "../src/billing.js"; +import { FREE_REASONS_SHOWN } from "../src/billing-pages.js"; +import { resetStripeCache } from "../src/stripe.js"; +import { monthOf, OBSERVE_HEADER, resetUsageCache } from "../src/usage.js"; +import { call, createApp, deliver, fakeStripe, GENCLASS_PORTAL, price, resetClock, sendBatch, setPlan, setup, signedIn, stripeEvent, subscription, WEBHOOK_SECRET } from "./account-helpers.js"; + +const LIVE = { STRIPE_SECRET_KEY: "sk_live_fake", STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET, STRIPE_PORTAL_CONFIGURATION: GENCLASS_PORTAL }; +const text = async (r: Promise | Response) => (await r).text(); +const json = async (r: Promise | Response) => (await (await r).json()) as any; + +beforeEach(() => { + resetClock(); + resetTokenCache(); + resetOwnerCache(); + resetStripeCache(); + resetUsageCache(); +}); + +/** A project's subscription as Stripe returns it: GenClass's Team price, its metadata naming the project. */ +const projectSub = (id: string, accountId: string, customer: string, projectId: string, status = "active") => + subscription(id, accountId, customer, "genclass_team_monthly", status, { metadata: { genclass: "1", genclass_account: accountId, genclass_project: projectId } }); + +/** An account with a Stripe customer GenClass made, and two apps. */ +async function buyer() { + const t = setup(LIVE); + const a = await signedIn(t.env); + const s = fakeStripe(); + t.sql.db.prepare("UPDATE accounts SET stripe_customer_id = 'cus_ada0000001', stripe_mode = 'live' WHERE id = ?").run(a.account.id); + s.genclassCustomer("cus_ada0000001", a.account.id); + const shop = await createApp(t.env, a.cookie, "Shop"); + const blog = await createApp(t.env, a.cookie, "Blog"); + return { ...t, ...a, s, shop, blog }; +} + +describe("plans.json version 4: Team per project", () => { + it("Team is $15 a project a month, not per seat; Free covers 3 projects and 100 live fixes a month each", () => { + const [free, team, enterprise] = PLANS; + expect(priceText(team!)).toBe("$15 / project / month"); + expect(team!.per_project).toBe(true); + expect(team!.per_seat).toBe(false); + expect(free!.limits).toEqual({ apps: 3, history_days: 7, seats: 1, live_fixes_per_month: 100 }); + expect(team!.limits.live_fixes_per_month).toBeNull(); + expect(enterprise!.limits.live_fixes_per_month).toBeNull(); + expect(free!.features.map((f) => f.text)).toContain("100 live fixes a month per project, then observe only"); + expect(team!.features.map((f) => f.text)).toContain("Unlimited live fixes"); + }); + + it("an account's grant of Team covers only the apps it was copied to; an Enterprise grant and an old account subscription cover them all", () => { + const at = new Date("2026-11-12T12:00:00Z"); + const later = "2027-02-01T00:00:00.000Z"; + expect(accountWide({ plan: "free", stripe_mode: null, comp_until: later, comp_plan: "team" }, "live", at).plan.id).toBe("free"); + expect(accountWide({ plan: "free", stripe_mode: null, comp_until: later, comp_plan: null }, "live", at).plan.id).toBe("enterprise"); + expect(accountWide({ plan: "team", stripe_mode: "live", comp_until: null }, "live", at)).toMatchObject({ source: "paid", plan: { id: "team" } }); + }); +}); + +describe("checkout for one project (POST /api/billing/checkout { plan, project_id })", () => { + it("Team is bought for a project of the account, once; nothing goes to Stripe otherwise", async () => { + const b = await buyer(); + const none = await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team" }, cookie: b.cookie, fetch: b.s.fetch }); + expect(none.status).toBe(400); + expect((await none.json() as any).error).toBe("the Team plan is bought for one project: send project_id"); + const other = await signedIn(b.env, "bob@example.com"); + const theirs = await createApp(b.env, other.cookie, "Theirs"); + expect((await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team", project_id: theirs.id }, cookie: b.cookie, fetch: b.s.fetch })).status).toBe(404); + expect(b.s.calls.filter((c) => c.url.pathname === "/v1/checkout/sessions" && c.method === "POST")).toEqual([]); + }); + + it("the session: quantity 1, the project in its metadata and its subscription's, back to the project's page", async () => { + const b = await buyer(); + const r = await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team", project_id: b.shop.id }, cookie: b.cookie, fetch: b.s.fetch }); + expect(r.status).toBe(200); + const session = b.s.calls.find((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions")!; + const form = Object.fromEntries(session.form!); + expect(form).toMatchObject({ + mode: "subscription", + customer: "cus_ada0000001", + "line_items[0][price]": "price_team000001", + "line_items[0][quantity]": "1", + // the "Add promotion code" field + allow_promotion_codes: "true", + "metadata[genclass_project]": b.shop.id, + "metadata[genclass_plan]": "team", + "subscription_data[metadata][genclass_project]": b.shop.id, + "subscription_data[metadata][genclass_account]": b.account.id, + success_url: `https://genclass.dev/account/billing?project=${b.shop.id}&done=1`, + cancel_url: `https://genclass.dev/account/billing?project=${b.shop.id}`, + }); + expect(session.form!.has("line_items[0][adjustable_quantity][enabled]")).toBe(false); + // the other project's checkout is a session of its own + expect((await call(b.env, "POST", "/api/billing/checkout", { body: { plan: "team", project_id: b.blog.id }, cookie: b.cookie, fetch: b.s.fetch })).status).toBe(200); + const sessions = b.s.calls.filter((c) => c.method === "POST" && c.url.pathname === "/v1/checkout/sessions").map((c) => c.form!.get("metadata[genclass_project]")); + expect(sessions).toEqual([b.shop.id, b.blog.id]); + }); +}); + +describe("the webhook writes a project's subscription to that project and touches nothing else", () => { + it("two projects, two subscriptions: each project gets its plan, the account row stays, nothing is cancelled or refunded", async () => { + const b = await buyer(); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const apply = async (sub: Record, type = "customer.subscription.created") => { + b.s.subs.set(sub.id as string, sub); + return json(deliver(b.env, stripeEvent(type, sub), b.s.fetch)); + }; + expect(await apply(projectSub("sub_shop000001", b.account.id, "cus_ada0000001", b.shop.id!))).toEqual({ received: true, result: "applied" }); + expect(await apply(projectSub("sub_blog000001", b.account.id, "cus_ada0000001", b.blog.id!))).toEqual({ received: true, result: "applied" }); + const rows = b.sql.rows("SELECT name, plan, subscription_id, subscription_status FROM projects ORDER BY name"); + expect(rows).toEqual([ + { name: "Blog", plan: "team", subscription_id: "sub_blog000001", subscription_status: "active" }, + { name: "Shop", plan: "team", subscription_id: "sub_shop000001", subscription_status: "active" }, + ]); + // the account's own plan is not changed by a project's subscription + expect(b.sql.rows("SELECT plan, subscription_id FROM accounts")[0]).toEqual({ plan: "free", subscription_id: null }); + // never a cancellation or a refund: each project is paid for on its own + expect(b.s.calls.filter((c) => c.method === "DELETE" || c.url.pathname.startsWith("/v1/refunds") || /\/cancel$/.test(c.url.pathname))).toEqual([]); + // the project's access is Team: 90 days of history, visitor analytics + const st = await json(call(b.env, "GET", `/api/account/projects/${b.shop.id}/stats?range=90d`, { cookie: b.cookie })); + expect(st.range).toBe("90d"); + expect(st.plan).toEqual({ id: "team", name: "Team", source: "paid" }); + expect(st.locked).toBeUndefined(); + // the subscription ends: the project goes back to Free + expect(await apply(projectSub("sub_shop000001", b.account.id, "cus_ada0000001", b.shop.id!, "canceled"), "customer.subscription.deleted")).toEqual({ received: true, result: "applied" }); + expect(b.sql.rows("SELECT plan, subscription_status FROM projects WHERE name = 'Shop'")[0]).toEqual({ plan: null, subscription_status: "canceled" }); + log.mockRestore(); + }); + + it("a second live subscription for a project that has one is not written and not cancelled; the owner is told", async () => { + const b = await buyer(); + const told: string[] = []; + const env = { ...b.env, NOTIFY_WEBHOOK: "https://hooks.example/notify" }; + const s = fakeStripe({ answer: (url, init) => (url.hostname === "hooks.example" ? (told.push(String(init.body)), new Response("ok")) : undefined) }); + s.genclassCustomer("cus_ada0000001", b.account.id); + const lines: string[] = []; + const log = vi.spyOn(console, "log").mockImplementation((...x: unknown[]) => void lines.push(x.join(" "))); + const apply = async (sub: Record) => { + s.subs.set(sub.id as string, sub); + return json(deliver(env, stripeEvent("customer.subscription.created", sub), s.fetch)); + }; + expect(await apply(projectSub("sub_shop000001", b.account.id, "cus_ada0000001", b.shop.id!))).toEqual({ received: true, result: "applied" }); + const second = await apply(projectSub("sub_shop000002", b.account.id, "cus_ada0000001", b.shop.id!)); + expect(second.result).toBe("ignored"); + expect(b.sql.rows("SELECT subscription_id FROM projects WHERE name = 'Shop'")[0]).toEqual({ subscription_id: "sub_shop000001" }); + expect(told.join("")).toContain("project_second_subscription"); + expect(s.calls.filter((c) => c.method === "DELETE" || c.url.pathname.startsWith("/v1/refunds"))).toEqual([]); + log.mockRestore(); + }); + + it("a project of another account named in the metadata is ignored", async () => { + const b = await buyer(); + const other = await signedIn(b.env, "bob@example.com"); + const theirs = await createApp(b.env, other.cookie, "Theirs"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const sub = projectSub("sub_x000000001", b.account.id, "cus_ada0000001", theirs.id); + b.s.subs.set(sub.id, sub); + expect((await json(deliver(b.env, stripeEvent("customer.subscription.created", sub), b.s.fetch))).result).toBe("ignored"); + expect(b.sql.rows("SELECT plan FROM projects WHERE name = 'Theirs'")[0]).toEqual({ plan: null }); + log.mockRestore(); + }); +}); + +describe("Free covers 3 projects; a fourth waits for a plan of its own", () => { + it("a fourth project is created, and its dashboard is locked with the way to its plan page", async () => { + const t = setup(LIVE); + const a = await signedIn(t.env); + const apps = []; + for (const n of ["a", "b", "c", "d"]) apps.push(await createApp(t.env, a.cookie, n)); + expect((await call(t.env, "GET", `/api/account/projects/${apps[2]!.id}/stats?range=7d`, { cookie: a.cookie })).status).toBe(200); + const locked = await call(t.env, "GET", `/api/account/projects/${apps[3]!.id}/stats?range=7d`, { cookie: a.cookie }); + expect(locked.status).toBe(402); + expect(await locked.json()).toMatchObject({ locked: true, needs_plan: true, upgrade_url: `https://genclass.dev/account/billing?project=${apps[3]!.id}` }); + // once one of the first three is paid for, the fourth is one of the three on Free + t.sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_status = 'active' WHERE id = ?").run(apps[0]!.id); + resetOwnerCache(); + expect((await call(t.env, "GET", `/api/account/projects/${apps[3]!.id}/stats?range=7d`, { cookie: a.cookie })).status).toBe(200); + }); + + it("the billing page lists each project with its plan; the project's page shows Team large and Free small", async () => { + const t = setup(LIVE); + const a = await signedIn(t.env); + const apps = []; + for (const n of ["Shop", "Blog", "Docs", "Extra"]) apps.push(await createApp(t.env, a.cookie, n)); + const page = await text(call(t.env, "GET", "/account/billing", { cookie: a.cookie })); + expect(page).toContain('

    $15 / project / month

    '); + expect(page).toContain('Choose a project'); + expect(page).toContain('id="projects"'); + expect(page).toContain(`ShopFree`); + expect(page).toContain("Needs a plan"); + const one = await text(call(t.env, "GET", `/account/billing?project=${apps[0]!.id}&from=cli`, { cookie: a.cookie })); + expect(one).toContain("

    Choose a plan for Shop

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

    Team

    $15 / project / month

    '); + expect(one).toContain('data-plan="team" data-pick>Start Team for Shop'); + // Free is the small way on, a very small button under the big card + // Free is the small way on: a very small link under the big card, and a popup that asks why before it goes on + expect(one).toContain('

    Try for free (limited)

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

    What\'s stopping you from paying?

    '); + for (const [val, words] of FREE_REASONS) expect(one).toContain(``); + expect(one).toContain(''); + expect(one).toContain("Start Team instead"); + // it never sends anyone back to a terminal: the choice is made here + expect(one.toLowerCase()).not.toContain("terminal"); + expect(one).toContain("Shop is in your account. Choose its plan to go on."); + // the fourth: no free way on + const fourth = await text(call(t.env, "GET", `/account/billing?project=${apps[3]!.id}`, { cookie: a.cookie })); + expect(fourth).toContain("needs a plan: Free covers 3 projects"); + expect(fourth).not.toContain("Try for free"); + // signed out: through the sign-in, back to the same page + const out = await call(t.env, "GET", `/account/billing?project=${apps[0]!.id}&start=1&from=cli`, {}); + expect(out.status).toBe(302); + expect(out.headers.get("location")).toBe(`/login?next=${encodeURIComponent(`/account/billing?project=${apps[0]!.id}&start=1&from=cli`)}`); + // the page waits for its payment with this + expect(await json(call(t.env, "GET", `/api/billing?project=${apps[0]!.id}`, { cookie: a.cookie }))).toEqual({ project: { id: apps[0]!.id, plan: "free", source: null, locked: null } }); + }); + + it("Try for free (limited) records the choice for that app only; the fourth cannot, and it is a browser write", async () => { + const t = setup(LIVE); + const a = await signedIn(t.env); + const apps = []; + for (const n of ["Shop", "Blog", "Docs", "Extra"]) apps.push(await createApp(t.env, a.cookie, n)); + const chosen = (id: string) => t.sql.db.prepare("SELECT plan_chosen FROM projects WHERE id = ?").get(id) as { plan_chosen: string | null }; + expect(chosen(apps[0]!.id).plan_chosen).toBeNull(); + // nobody goes on with Free without saying why: a reason from the list is needed + expect((await call(t.env, "POST", `/api/billing/projects/${apps[0]!.id}/free`, { cookie: a.cookie, body: {} })).status).toBe(400); + expect((await call(t.env, "POST", `/api/billing/projects/${apps[0]!.id}/free`, { cookie: a.cookie, body: { reason: "because" } })).status).toBe(400); + expect(chosen(apps[0]!.id).plan_chosen).toBeNull(); + const told: string[] = []; + const hook = (async (u: string | URL | Request, init?: RequestInit) => (String(u).startsWith("https://hooks.example/") ? (told.push(String(init?.body)), new Response("ok")) : new Response("no", { status: 500 }))) as never; + const env = { ...t.env, NOTIFY_WEBHOOK: "https://hooks.example/notify" }; + const r = await call(env, "POST", `/api/billing/projects/${apps[0]!.id}/free`, { cookie: a.cookie, body: { reason: "price", note: " too much\u0007 for a side project " }, fetch: hook }); + expect(r.status).toBe(200); + expect(t.sql.rows("SELECT account_id, project_id, reason, note FROM plan_feedback")).toEqual([{ account_id: a.account.id, project_id: apps[0]!.id, reason: "price", note: "too much for a side project" }]); + expect(told).toHaveLength(1); + expect(JSON.parse(told[0]!)).toMatchObject({ kind: "free_chosen", reason: "price", project: apps[0]!.id, text: 'Free chosen for "Shop" (ada@example.com): It costs too much. "too much for a side project"' }); + expect(await r.json()).toEqual({ ok: true, plan: "free" }); + expect(chosen(apps[0]!.id).plan_chosen).toMatch(/^20\d\d-\d\d-\d\dT/); + expect(chosen(apps[1]!.id).plan_chosen).toBeNull(); + // the fourth has only the paid plan + expect((await call(t.env, "POST", `/api/billing/projects/${apps[3]!.id}/free`, { cookie: a.cookie, body: { reason: "trying" } })).status).toBe(409); + expect(chosen(apps[3]!.id).plan_chosen).toBeNull(); + // another account's app, and no app at all + const b = await signedIn(t.env, "bob@example.com"); + expect((await call(t.env, "POST", `/api/billing/projects/${apps[1]!.id}/free`, { cookie: b.cookie, body: { reason: "trying" } })).status).toBe(404); + expect(chosen(apps[1]!.id).plan_chosen).toBeNull(); + // signed out, and a GET + expect((await call(t.env, "POST", `/api/billing/projects/${apps[1]!.id}/free`, { body: { reason: "trying" } })).status).toBe(401); + expect((await call(t.env, "GET", `/api/billing/projects/${apps[1]!.id}/free`, { cookie: a.cookie })).status).toBe(405); + expect(t.sql.rows("SELECT COUNT(*) AS n FROM plan_feedback")).toEqual([{ n: 1 }]); + // the popup lists exactly the reasons the route takes + expect(FREE_REASONS_SHOWN).toEqual(FREE_REASONS); + }); + + it("the website: an app on Free that has not chosen opens its plan page instead of its dashboard, until it has", async () => { + const t = setup(LIVE); + const a = await signedIn(t.env); + const shop = await createApp(t.env, a.cookie, "Shop"); + const r = await call(t.env, "GET", `/account/apps/${shop.id}`, { cookie: a.cookie }); + expect(r.status).toBe(302); + expect(r.headers.get("location")).toBe(`/account/billing?project=${shop.id}`); + await call(t.env, "POST", `/api/billing/projects/${shop.id}/free`, { cookie: a.cookie, body: { reason: "unsure" } }); + expect((await call(t.env, "GET", `/account/apps/${shop.id}`, { cookie: a.cookie })).status).toBe(200); + // on Team: never sent there + const blog = await createApp(t.env, a.cookie, "Blog"); + t.sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_id = 'sub_b', subscription_status = 'active' WHERE id = ?").run(blog.id); + expect((await call(t.env, "GET", `/account/apps/${blog.id}`, { cookie: a.cookie })).status).toBe(200); + // where nothing is sold, nothing to choose + const none = setup(); + const n = await signedIn(none.env); + const app = await createApp(none.env, n.cookie, "Docs"); + expect((await call(none.env, "GET", `/account/apps/${app.id}`, { cookie: n.cookie })).status).toBe(200); + }); + + it("an older plan link (?plan=team&start=1, from the pricing page or an older genclass command) opens the first app on Free", async () => { + const t = setup(LIVE); + const a = await signedIn(t.env); + // no app yet: the billing page, which says to add one first + const none = await call(t.env, "GET", "/account/billing?plan=team&start=1", { cookie: a.cookie }); + expect(none.status).toBe(200); + expect(await none.text()).toContain("Add a project first"); + const shop = await createApp(t.env, a.cookie, "Shop"); + const blog = await createApp(t.env, a.cookie, "Blog"); + const r = await call(t.env, "GET", "/account/billing?plan=team&start=1", { cookie: a.cookie }); + expect(r.status).toBe(302); + expect(r.headers.get("location")).toBe(`/account/billing?project=${shop.id}&from=cli`); + // Shop on Team: Blog is the one + t.sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_id = 'sub_s', subscription_status = 'active' WHERE id = ?").run(shop.id); + expect((await call(t.env, "GET", "/account/billing?plan=team", { cookie: a.cookie })).headers.get("location")).toBe(`/account/billing?project=${blog.id}`); + }); + + it("a project that is paid for is not deleted while it bills", async () => { + const t = setup(LIVE); + const a = await signedIn(t.env); + const app = await createApp(t.env, a.cookie, "Shop"); + t.sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_id = 'sub_s', subscription_status = 'active' WHERE id = ?").run(app.id); + const r = await call(t.env, "DELETE", `/api/account/projects/${app.id}`, { body: { confirm: "delete" }, cookie: a.cookie }); + expect(r.status).toBe(409); + expect((await r.json() as any).error).toContain("cancel it in Manage billing"); + t.sql.db.prepare("UPDATE projects SET subscription_status = 'canceled', plan = NULL WHERE id = ?").run(app.id); + expect((await call(t.env, "DELETE", `/api/account/projects/${app.id}`, { body: { confirm: "delete" }, cookie: a.cookie })).status).toBe(204); + }); +}); + +describe("problems fixed live: 100 a month per project on Free, then observe only", () => { + /** A page load in guard mode in which the runtime fixed `n` problems. */ + const fixes = (n: number) => Array.from({ length: n }, (_, i) => ({ t: "action", seq: i + 1, action: "coalesce", outcome: "applied" })); + + it("past the limit, ingest's reply tells the runtime to observe only; the batch is stored and counted the same; Team lifts it", async () => { + const t = setup(LIVE); + const a = await signedIn(t.env); + const app = await createApp(t.env, a.cookie, "Shop"); + const first = await sendBatch(t.env, app.token, [{ t: "session", host: "shop.example", route: "/" }, ...fixes(60)], { sid: "aaaaaaaaaaaa" }); + expect(first.headers.get(OBSERVE_HEADER)).toBeNull(); + expect(first.headers.get("access-control-expose-headers")).toBe(OBSERVE_HEADER); + await sendBatch(t.env, app.token, [{ t: "session", host: "shop.example", route: "/" }, ...fixes(60)], { sid: "bbbbbbbbbbbb" }); + resetUsageCache(); + const past = await sendBatch(t.env, app.token, [{ t: "session", host: "shop.example", route: "/" }], { sid: "cccccccccccc" }); + expect(past.status).toBe(202); + expect(past.headers.get(OBSERVE_HEADER)).toBe("live-fix-limit"); + // what the dashboard and the CLI read + const st = await json(call(t.env, "GET", `/api/account/projects/${app.id}/stats?range=7d`, { cookie: a.cookie })); + expect(st.usage).toEqual({ used: 120, limit: 100, observe_only: true, resets: monthOf(new Date(st.generated)).resets }); + expect(st.plan_url).toBe(`https://genclass.dev/account/billing?project=${app.id}`); + // the project on Team: no limit, no header + t.sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_status = 'active' WHERE id = ?").run(app.id); + resetOwnerCache(); + resetUsageCache(); + const paid = await sendBatch(t.env, app.token, [{ t: "session", host: "shop.example", route: "/" }], { sid: "dddddddddddd" }); + expect(paid.headers.get(OBSERVE_HEADER)).toBeNull(); + expect((await json(call(t.env, "GET", `/api/account/projects/${app.id}/stats?range=7d`, { cookie: a.cookie }))).usage).toMatchObject({ limit: null, observe_only: false }); + }); + + it("the CLI's overview names each project's plan and its page; its stats carry the usage", async () => { + const t = setup(LIVE); + const a = await signedIn(t.env); + for (const n of ["a", "b", "c", "d"]) await createApp(t.env, a.cookie, n); + const { cliToken } = await import("./account-helpers.js"); + const bearer = await cliToken(t.env, a.cookie); + const o = await json(call(t.env, "GET", "/api/cli/overview", { bearer, origin: null })); + expect(o.projects.map((p: any) => [p.name, p.plan.id, p.needs_plan])).toEqual([["a", "free", false], ["b", "free", false], ["c", "free", false], ["d", "free", true]]); + expect(o.projects[0].plan_url).toBe(`https://genclass.dev/account/billing?project=${o.projects[0].id}`); + }); +}); + +it("FREE is the first plan", () => expect(FREE.id).toBe("free")); +it("price() still names Team's price", () => expect(price("genclass_team_monthly").id).toBe("price_team000001")); +it("setPlan is available to the tests", () => expect(typeof setPlan).toBe("function")); diff --git a/telemetry-worker/test/team.test.ts b/telemetry-worker/test/team.test.ts index 6478ecb3..075ca9d7 100644 --- a/telemetry-worker/test/team.test.ts +++ b/telemetry-worker/test/team.test.ts @@ -1,4 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +// Seats are tested on billing/plans.json version 3 (2026-11-11), where Team was sold per seat: since version 4 Team is +// per project and holds everyone, and the seat rules (src/team.ts) stay for any plan sold per seat. +vi.mock("../../billing/plans.json", async () => ({ default: (await import("./fixtures/plans-v3-seats.json")).default })); import { authRetention } from "../src/auth.js"; import { resetTokenCache } from "../src/dashboard.js"; import { resetOwnerCache } from "../src/paid.js"; @@ -6,11 +9,9 @@ import { resetStripeCache } from "../src/stripe.js"; import { activeOwners, INVITE_MS } from "../src/team.js"; import { advance, call, cliToken, createApp, detection, GENCLASS_PORTAL, now, resetClock, sendBatch, setPlan, setup, signedIn, WEBHOOK_SECRET } from "./account-helpers.js"; -// Teams on the plans as they were sold per seat until 2026-11-12 (test/fixtures/plans-per-seat.json, plans.json -// version 3 with Team per_seat; src/team.ts, src/team-routes.ts): Free is one person, Team holds the seats paid for -// (the owner first, then members in joining order), a grant has no seat count. Members read the owner's dashboards -// with the owner's plan and change nothing. Team is sold per app since (any number of members): test/plans.test.ts. -vi.mock("../../billing/plans.json", async () => ({ default: (await import("./fixtures/plans-per-seat.json")).default })); +// Teams on the real plans (billing/plans.json version 3; src/team.ts, src/team-routes.ts): Free is one person, Team +// holds the seats paid for (the owner first, then members in joining order), a grant has no seat count. Members read +// the owner's dashboards with the owner's plan and change nothing. const LIVE = { STRIPE_SECRET_KEY: "sk_live_fake", STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET, STRIPE_PORTAL_CONFIGURATION: GENCLASS_PORTAL }; const PLANS_URL = "https://genclass.dev/account/billing";