diff --git a/billing/plans.json b/billing/plans.json index 59cbcaf0..7fc2c8b8 100644 --- a/billing/plans.json +++ b/billing/plans.json @@ -1,5 +1,5 @@ { - "_about": "The one place GenClass plans are defined. telemetry-worker/src/plans.ts imports this file (limits, entitlements, Stripe lookup keys, the billing page) and genclass.dev's site reads it (the pricing page). Three plans since version 3 (2026-11-11): Free, Team and Enterprise; since version 4 (2026-11-12) a plan belongs to a project, not to an account. Free is what finds, shows and fixes the problems: the open-source runtime with every mode and its on-device model, the coding-agent loop on the developer's machine, the dashboard's problems for an account's first 3 projects (the last 7 days shown; a 4th project on Free is locked until it has a plan), and 100 problems fixed live a month per project (limits.live_fixes_per_month; past it the runtime is told to observe only until the month ends) and genclass-dev[bot] on public repositories. Team is sold per project (per_project true: one Stripe subscription of quantity 1 per project, its metadata naming the project; everyone on the account's team sees it; telemetry-worker/src/stripe.ts) and adds what a team uses: genclass-dev[bot] on private repositories, visitor analytics, forwarding, export, 90 days of history and members. Enterprise is agreed by email: a null monthly price means the plan is not sold on the site, and an account gets it from GenClass's own grant (accounts.comp_until, set by hand to the end of the agreement; accounts.comp_plan names the plan granted, grant_plan when it is empty). The classifier a person from GenClass trains is in Enterprise only. retired_plans lists the plan ids GenClass sold before (Pro and Custom until 2026-10-19, Individual until 2026-10-22): their Stripe products and prices stay GenClass's own, so a subscription to one is still cancelled with its account, opens Manage billing and is reported to the owner while it bills, but it gives no plan (it reads as Free). The worker's Stripe code and scripts/stripe-setup.mjs sell any plan given a monthly price above 0, its stripe_lookup_keys and a stripe_tax_code. Prices are US cents (per project for a per_project plan, per seat for a per_seat plan); a null yearly price means no yearly option. limits.apps null means no plan limit (the worker still caps an account at 200 apps against abuse); limits.seats is how many people a plan that is not per_seat holds (null: no limit); limits.history_days is what the dashboard shows, and data is never deleted before 90 days whatever the plan, so the longer history is there the moment an account upgrades. Ingest never depends on the plan: an app's visitor analytics are counted while its plan leaves them out, and shown once it includes them. Pro (id team; named Team until 2026-11-12) has a trial: trial_days (3) days free at Checkout, a card taken up front and charged when it ends unless cancelled, once per account (telemetry-worker/src/billing.ts trialFor). Feature lines with available false are shown as coming next. The entitlement github_private is what lets genclass-dev[bot], the GitHub App, review an account's private repositories (telemetry-worker/src/github/entitlement.ts); public repositories are reviewed for everyone, whatever this file says. A self-hosted collector sets PLAN_GATES = \"open\" and gives every account the grant plan. managed_payments true would sell a plan through Stripe Managed Payments (only for fully automated digital products; telemetry-worker/README.md, Paid plans). `suggestions` (2026-11-12): what `genclass suggest` gives a plan (telemetry-worker/src/suggest.ts): `per_day`, model runs an account has a day, and `shown`, how many items of the plan it is shown (null: all); the measured checks themselves are free for everyone. Entitlements `problems` (the problem lists, pages, activity and fix prompts on the dashboard and in the genclass command; Free shows only the totals) and `ask` (genclass ask) were added 2026-11-12 (the owner's 'limit free fully'); a plan file without the key gives them. Version 5 (2026-11-13, the owner's call): Pro is bought for the account again, not per app, and per developer (per_seat: the subscription's quantity is how many developers, the owner included, US$15 each a month); it covers up to 3 apps (limits.apps) whatever the number of developers, the oldest first, and a later app waits on its plan page, where more apps are Enterprise's, by email. A subscription bought per project under version 4 still covers its app. Free covers one app. GenClass's grants cover every app. The genclass command is Pro's (2026-11-13, the owner's 'make the entire CLI paid'): a CLI session without Pro, its trial or a paying team gets 402 from every /api/cli route but the overview and the account; Free is the website's, with the owner's words for its list: one app, a limited dashboard, the open-source basics, and (the owner's 'keep them free') the GitHub App on public repositories, the page check and 100 live fixes a month.", + "_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 (named Pro since 2026-11-12) and Enterprise; version 4 (2026-11-12) sold a plan per project; version 5 (2026-11-13, below) sells Pro for the account again. The history up to version 4 follows. Free is what finds, shows and fixes the problems: the open-source runtime with every mode and its on-device model, the coding-agent loop on the developer's machine, the dashboard's problems for an account's first 3 projects (the last 7 days shown; a 4th project on Free is locked until it has a plan), and 100 problems fixed live a month per project (limits.live_fixes_per_month; past it the runtime is told to observe only until the month ends) and genclass-dev[bot] on public repositories. Team is sold per project (per_project true: one Stripe subscription of quantity 1 per project, its metadata naming the project; everyone on the account's team sees it; telemetry-worker/src/stripe.ts) and adds what a team uses: genclass-dev[bot] on private repositories, visitor analytics, forwarding, export, 90 days of history and members. Enterprise is agreed by email: a null monthly price means the plan is not sold on the site, and an account gets it from GenClass's own grant (accounts.comp_until, set by hand to the end of the agreement; accounts.comp_plan names the plan granted, grant_plan when it is empty). The classifier a person from GenClass trains is in Enterprise only. retired_plans lists the plan ids GenClass sold before (Pro and Custom until 2026-10-19, Individual until 2026-10-22): their Stripe products and prices stay GenClass's own, so a subscription to one is still cancelled with its account, opens Manage billing and is reported to the owner while it bills, but it gives no plan (it reads as Free). The worker's Stripe code and scripts/stripe-setup.mjs sell any plan given a monthly price above 0, its stripe_lookup_keys and a stripe_tax_code. Prices are US cents (per project for a per_project plan, per seat for a per_seat plan); a null yearly price means no yearly option. limits.apps null means no plan limit (the worker still caps an account at 200 apps against abuse); limits.seats is how many people a plan that is not per_seat holds (null: no limit); limits.history_days is what the dashboard shows, and data is never deleted before 90 days whatever the plan, so the longer history is there the moment an account upgrades. Ingest never depends on the plan: an app's visitor analytics are counted while its plan leaves them out, and shown once it includes them. Pro (id team; named Team until 2026-11-12) has a trial: trial_days (3) days free at Checkout, a card taken up front and charged when it ends unless cancelled, once per account (telemetry-worker/src/billing.ts trialFor). Feature lines with available false are shown as coming next. The entitlement github_private is what lets genclass-dev[bot], the GitHub App, review an account's private repositories (telemetry-worker/src/github/entitlement.ts); public repositories are reviewed for everyone, whatever this file says. A self-hosted collector sets PLAN_GATES = \"open\" and gives every account the grant plan. managed_payments true would sell a plan through Stripe Managed Payments (only for fully automated digital products; telemetry-worker/README.md, Paid plans). `suggestions` (2026-11-12): what `genclass suggest` gives a plan (telemetry-worker/src/suggest.ts): `per_day`, model runs an account has a day, and `shown`, how many items of the plan it is shown (null: all); the measured checks themselves are free for everyone. Entitlements `problems` (the problem lists, pages, activity and fix prompts on the dashboard and in the genclass command; Free shows only the totals) and `ask` (genclass ask) were added 2026-11-12 (the owner's 'limit free fully'); a plan file without the key gives them. Version 5 (2026-11-13, the owner's call): Pro is bought for the account again, not per app, and per developer (per_seat: the subscription's quantity is how many developers, the owner included, US$15 each a month); it covers up to 3 apps (limits.apps) whatever the number of developers, the oldest first, and a later app waits on its plan page, where more apps are Enterprise's, by email. A subscription bought per project under version 4 still covers its app, is not counted in the 3 and holds the whole team (no seat count); an account subscription from before version 5 keeps covering the apps the account had when migration 0021 was applied (accounts.apps_kept) while it lasts. Free covers one app. GenClass's grants cover every app. The genclass command is Pro's (2026-11-13, the owner's 'make the entire CLI paid'): a CLI session without Pro, its trial or a paying team gets 402 from every /api/cli route but the overview and the account; Free is the website's, with the owner's words for its list: one app, a limited dashboard, the open-source basics, and (the owner's 'keep them free') the GitHub App on public repositories, the page check and 100 live fixes a month.", "version": 5, "currency": "usd", "contact": "mehar@genclass.dev", diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md index e79add98..55df4082 100644 --- a/telemetry-worker/README.md +++ b/telemetry-worker/README.md @@ -4,10 +4,12 @@ Cloudflare Worker that receives the default-on diagnostics batches from `@gencla [`packages/runtime/TELEMETRY.md`](../packages/runtime/TELEMETRY.md)) and stores them in R2, that gives every web app 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 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. +([Accounts](#accounts)). There are three plans (`billing/plans.json` version 5, since 2026-11-13): Free, one app with +its totals, on the website; Pro (plan id `team`), US$15 per developer a month for up to 3 apps, with the genclass +command, every problem, visitor analytics, forwarding, export, 90 days of history, private repositories for the +GitHub App and members ([Teams](#teams)), 3 days free at the first Checkout; and Enterprise, by agreement, with more +apps and your own classifier, which GenClass trains ([Paid plans](#paid-plans)). What was bought before 13 November +2026 keeps covering what it covered (migration 0021). - **Deployed:** worker `genclass-telemetry`, account `144bd5f5270b51dbe7faf46227a154f0`. - `https://genclass-telemetry.mehar-144.workers.dev` (workers.dev). Runtime `0.2.1` and earlier send to this URL + @@ -551,7 +553,20 @@ GitHub App on public repositories, the page check, 100 live fixes a month. A new (`/account` with no app, on Free, where Pro is sold) is Pro's trial: "Start your 3-day free trial" large (the account's Checkout, `/account/billing?plan=team&start=1`) and a tiny "Skip" on to its first app (the owner: "our entire goal is for them to put their credit cards in"). On an app's plan page Free is the same tiny "Skip", and -the reason popup still comes before it. +the reason popup still comes before it. Both show Pro small and short (the owner: "a tad bit zoomed in and there's +too much written"): the name, the price, the first 5 lines of the plan (`plans.ts` pitchLines, without "Everything in +Free" and the trial's own line) and the rest folded under "N more"; a just-added app's page has one line under its +heading, and the month's live fixes show once there are some. + +What was bought before version 5 keeps what it covered (the Terms; migration 0021, applied before the worker that +reads it is deployed: `npx --yes wrangler@4 d1 migrations apply genclass-dashboard --remote`). A subscription bought per +app under version 4 keeps its app covered, the app is not counted in Pro's 3, and its team keeps every member (no seat +count). An account subscription paying when 0021 is applied keeps covering as many apps as the account had then +(`accounts.apps_kept`) until that subscription ends or another replaces it (the webhook clears it). Every app a paid +plan or a grant covered then counts as chosen (`projects.plan_chosen`), so it opens its dashboard as before instead of +"Choose a plan". A paid Pro under GenClass's grant of Pro keeps the grant's room (every app, no seat limit). A Free +account keeps one app, its oldest (the owner's call); the locked page says that Free covers one app since 13 November +2026. The plans live in [`billing/plans.json`](../billing/plans.json) (version 3, 2026-11-11, the owner: "cut back on the free features"; one thing stays free, finding, seeing and fixing the problems): @@ -641,10 +656,14 @@ 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 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-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 + from its plan (`src/plans.ts` accessOf, `seats`): Free 1; Pro (per developer since version 5) 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-v3-seats.json`), and no limit while they are not known (a + subscription bought per project under version 4 included the whole team; a row whose seats the webhook has not + written yet); with one seat the owner is told to add seats in Manage billing; a plan GenClass grants (a paid plan + under a grant of the same plan too), and every account on a server with `PLAN_GATES = "open"`, no limit. A member + works under its team's plan in the genclass command (`src/cli.ts` workingAccess: suggestions, `ask`, the plan it + names; `cli.via` is `"team"`). 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. - **What a member can do.** Open the dashboards and read the stats of the owner's apps, shown with the owner's plan @@ -836,7 +855,7 @@ does about it. | `GET /api/billing` | browser session: `{ plan, subscription_status, current_period_end, cancel_at_period_end, on_sale, access, seats }` (`on_sale`: a plan is sold and this account can buy it); `access` is `paid`, `comp` (GenClass's grant), `open` (this server's `PLAN_GATES`) or null (Free); `seats`: the people the account holds, null for no limit | | `POST /api/billing/trial` | 410: GenClass has no trial any more ([Grants and no trial](#grants-enterprise-and-no-trial)) | | `GET /account/trial` | `/account`, through `/signup` when signed out (genclass.dev's pricing page linked here until 2026-10-19) | -| `GET /account/billing?project=` | one app's plan page (plans per project, `billing/plans.json` version 4): "Choose a plan for ", Team large with its price, what it has and Start Team for (a Checkout for that project: `project_id`, metadata `genclass_project`), and a very small "Skip" under it (Free, after the reason popup; it read "Try for free (limited)" until 2026-11-13), none for an app past the one Free covers; the app's live fixes this month. `&from=cli` (the genclass command's plan step, which waits for the choice), `&new=1` (an app just added on the website, which goes on to its setup), `&start=1` (opens the Checkout at once). `?plan=team` without a project opens the plan page of the account's first app on Free. | +| `GET /account/billing?project=` | one app's plan page (`billing/plans.json` version 5): "Choose a plan for ", Pro's card (its name, price, first 5 lines and "N more"), Start your 3-day free trial (the account's Checkout, coming back here) or Continue with Pro for a paying account, and a very small "Skip" under it (Free, after the reason popup; it read "Try for free (limited)" until 2026-11-13), none for an app past what the account's plan covers (Free: since 13 November 2026 one app, the oldest; Pro: Enterprise by email); the app's live fixes this month, once there are some. `&from=cli` (the genclass command's plan step, which waits for the choice; the choice goes on to the app's dashboard), `&new=1` (an app just added on the website, which goes on to its setup), `&start=1` (opens the Checkout at once). `?plan=team` without a project opens the plan page of the account's first app on Free. | | `POST /api/billing/projects//free` | `{ reason, note? }`, browser session and our `Origin`: Free chosen for one app on its plan page, after the popup "What's stopping you from paying?" (`reason` one of `FREE_REASONS` in `src/billing.ts`: price, trying, approval, feature, unsure, other; `note` at most 500 characters). Keeps the answer (`plan_feedback`, migration 0017), writes `projects.plan_chosen` (which `GET /api/cli/overview` reports as `plan_chosen`) and tells the owner (`NOTIFY_WEBHOOK`, kind `free_chosen`). 400 without a reason, 409 for an app past the ones Free covers, 404 for another account's app. Until an own app on Free has a choice (where Team is sold), `/account/apps/` redirects to its plan page. | | `GET /account/billing` | the plan, its renewal or end date, the plans side by side (Free, Team and Enterprise), the seats box and Upgrade (and Switch, with two paid plans) buttons, the Team section (members, invite links, the teams the account is in) and Manage billing (only to an account that can buy), Talk to us (a mailto link) for Enterprise, and which plan is sold through Link (Managed Payments; none today). With no plan sold it shows no Upgrade button, no "billed monthly" and no Stripe footer. `?plan=` (a pricing page button) survives the sign-in redirect and highlights and focuses that plan's button. Back from Checkout (`?done=1`) while the plan still reads Free, or back from a switch (`?done=1&to=`) while the plan is still the old one, it shows no buttons and waits for the webhook. | | `GET /api/account/projects//integrations` | browser session: the app's forwarding destinations, masked | diff --git a/telemetry-worker/migrations/0021_plans_v5_kept.sql b/telemetry-worker/migrations/0021_plans_v5_kept.sql new file mode 100644 index 00000000..0be22c46 --- /dev/null +++ b/telemetry-worker/migrations/0021_plans_v5_kept.sql @@ -0,0 +1,25 @@ +-- billing/plans.json version 5 (2026-11-13): Pro for the account, per developer, covering up to 3 apps; Free one app; +-- every new app opens "Choose a plan" first. What was bought before keeps what it covered (the Terms: plans bought +-- before 13 November 2026 keep covering the apps they covered): +-- - accounts.apps_kept: for an account paying when this is applied (a Team subscription of the whole account, from +-- version 3, or one bought per app), how many apps it has then. Its plan covers at least that many while that same +-- subscription lasts (src/plans.ts coverage); the webhook clears it when the subscription ends or another one +-- replaces it. A subscription bought per project (version 4) needs nothing here: its app is always covered and is +-- not counted in the 3. +-- - projects.plan_chosen: an app that a paid plan or a grant covers when this is applied (its own, its account's, or +-- an account with an app that pays for itself) counts as chosen, so it opens its dashboard as before. +-- Apply before deploying the worker that reads it: +-- npx --yes wrangler@4 d1 migrations apply genclass-dashboard --remote + +ALTER TABLE accounts ADD COLUMN apps_kept INTEGER; + +UPDATE accounts SET apps_kept = (SELECT COUNT(*) FROM projects WHERE projects.account_id = accounts.id) + WHERE plan IS NOT NULL AND plan != 'free' AND subscription_id IS NOT NULL; + +UPDATE projects SET plan_chosen = created + WHERE plan_chosen IS NULL AND account_id IS NOT NULL AND ( + (plan IS NOT NULL AND plan != 'free') + OR comp_until > strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + OR account_id IN (SELECT id FROM accounts WHERE (plan IS NOT NULL AND plan != 'free') OR comp_until > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + OR account_id IN (SELECT account_id FROM projects WHERE plan IS NOT NULL AND plan != 'free' AND account_id IS NOT NULL) + ); diff --git a/telemetry-worker/src/account-pages.ts b/telemetry-worker/src/account-pages.ts index 28c94e52..6db11f7f 100644 --- a/telemetry-worker/src/account-pages.ts +++ b/telemetry-worker/src/account-pages.ts @@ -13,6 +13,19 @@ import { DASH_CSS, embed, esc, LOGO, orgName, RUNTIME_PKG, shell, SIGNED_IN_NAV import { icon, studioPage, type ProductMenu, type RailItem } from "./studio.js"; import { entitled, FREE } from "./plans.js"; +/** How many feature lines a pitch shows before "N more" (the owner, 2026-11-13: less written on the plan). */ +export const PITCH_SHOWN = 5; + +/** + * A pitch's feature lines: the first PITCH_SHOWN in a list, the rest folded under "N more" (a details element, no + * script). Each line goes through esc(). + */ +export function pitchList(lines: string[], shown = PITCH_SHOWN): string { + const li = (t: string) => `
  • ${esc(t)}
  • `; + const rest = lines.slice(shown); + return `
      ${lines.slice(0, shown).map(li).join("")}
    ${rest.length ? `
    ${rest.length} more
      ${rest.map(li).join("")}
    ` : ""}`; +} + // --------------------------------------------------------------------------------------------- styles, icons export const ACCOUNT_CSS = ` @@ -283,7 +296,10 @@ export interface AuthPageOptions { notice?: string | null; } -/** The way in without an account, under the sign-in page: a dashboard of its own, protected (/start, link-lock.ts). */ +/** + * The way in without an account, under the sign-in page: a dashboard of its own, protected (/start, link-lock.ts). + * Not on a sign-in the genclass command started (next is /login/device): that one needs the account. + */ const optional = (): string => `

    Just trying it? Create a dashboard without an account.

    `; /** The passkey sign-in button's script: sign in, then go to `next` (or reload, on the device page). */ @@ -327,7 +343,7 @@ ${providers} -${optional()} +${o.next?.startsWith("/login/device") ? "" : optional()} `; return authPage(nonce, mode === "in" ? "Sign in | GenClass" : "Create an account | GenClass", inner, ENTRY_JS); } @@ -606,7 +622,7 @@ ${signInWays({ ...v.providers, next: v.next })} function answer(ok){ err.textContent = ""; yes.disabled = true; no.disabled = true; gcPost("/api/device/approve", { user_code: cfg.code, approve: ok }).then(function(){ - if (ok) finish("Approved", "Go back to your terminal; it finishes on its own. You can close this tab."); + if (ok) finish("Approved", "Your terminal is signed in. You can close this tab."); else finish("Denied", "The terminal stops waiting and signs nothing in."); }, function(e){ if (e.status === 404) { gone(); return; } @@ -660,7 +676,7 @@ export interface AccountView { * A new account on Free that can buy (no app yet): its first screen is the paid plan's trial, with a tiny Skip on to * its first app (the owner's call, 2026-11-13: "our entire goal is for them to put their credit cards in"). */ - trial?: { plan: string; price: string; days: number | null; summary: string; features: string[]; start: string } | null; + trial?: { plan: string; price: string; days: number | null; features: string[]; start: string } | null; } const TRANSPORT_WORDS: Record = { @@ -695,12 +711,17 @@ export const ORG_CSS = ` .empty-p{grid-column:1/-1;display:flex;flex-direction:column;align-items:center;gap:12px;padding:56px 16px;border:1px dashed var(--rule2);border-radius:6px;text-align:center} .empty-p h2{font-size:16px} .empty-p p{font-size:13px;color:var(--ink3);max-width:52ch} -.empty-p.trial{border-style:solid;border-color:var(--accent);gap:10px;padding:44px 20px 28px} -.empty-p.trial h2{font-size:28px;letter-spacing:-.01em} -.empty-p.trial .tp{font-size:17px;color:var(--ink);font-weight:600} -.empty-p.trial ul{list-style:none;padding:0;margin:4px 0 6px;display:grid;gap:6px;text-align:left;font-size:14px} +.empty-p.trial{border-style:solid;border-color:var(--accent);gap:10px;padding:32px 20px 24px} +.empty-p.trial h2{font-size:22px;letter-spacing:-.01em} +.empty-p.trial .tp{font-size:14px;color:var(--ink);font-weight:600} +.empty-p.trial ul{list-style:none;padding:0;margin:2px 0 0;display:grid;gap:5px;text-align:left;font-size:13px} .empty-p.trial li::before{content:"\\2713";color:var(--accent);margin-right:8px} -.empty-p.trial .btn{padding:14px 26px;font-size:16px} +.empty-p.trial .pmore{font-size:12.5px;color:var(--ink3);margin-bottom:4px} +.empty-p.trial .pmore ul{margin-top:5px} +.pmore summary{cursor:pointer;list-style:none;color:var(--ink3);text-decoration:underline;text-underline-offset:2px} +.pmore summary::-webkit-details-marker{display:none} +.pmore[open] summary{display:none} +.empty-p.trial .btn{padding:11px 22px;font-size:14.5px} .empty-p.trial .skipit{font-size:11px;margin-top:6px} .empty-p.trial .skipit a{color:var(--ink3)} .acct-form{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;margin-top:16px;max-width:720px} @@ -801,10 +822,10 @@ export function accountPage(nonce: string, v: AccountView): string { ` : v.trial ? `

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

    -

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

    ${esc(v.trial.summary)}

    -
      ${v.trial.features.map((f) => `
    • ${esc(f)}
    • `).join("")}
    +

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

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

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

    ` : ""} + ${v.trial.days ? `

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

    ` : ""}

    Skip

    ` : `

    Create your first project

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

    ${icon("plus", 14)}New project
    `; // with the GitHub App set up on this server, its page sits next to New project (src/github/pages.ts) diff --git a/telemetry-worker/src/auth.ts b/telemetry-worker/src/auth.ts index 014d28d6..b2f10562 100644 --- a/telemetry-worker/src/auth.ts +++ b/telemetry-worker/src/auth.ts @@ -82,7 +82,7 @@ import { forgetAccount as forgetAccountGithub, unlinkProject } from "./github/st import { json, mediaType, readCapped } from "./http.js"; import { dashboardPage, ownEndpoint, securityHeaders, setupCommand } from "./pages.js"; import { DEVICE_FAILURES, DEVICE_MS, DUMMY_HASH, FAILURE_WINDOW_MS, FREE_FAILURES, MAX_DELAY_MS, PASSWORD_MAX, delayAfter, hashPassword, needsRehash, passwordProblem, verifyPassword, waitText } from "./password.js"; -import { accountAccess, accountBilling, canBuy, firstPlanWith, FREE, hasAnalytics, hasDashboard, hasProblems, keepDays, PAID_PLANS, pickRange, PLANS_FILE, priceText, problemsLock, projectAccess, viewRanges } from "./plans.js"; +import { accountAccess, accountBilling, canBuy, firstPlanWith, FREE, hasAnalytics, hasDashboard, hasProblems, keepDays, PAID_PLANS, pickRange, pitchLines, PLANS_FILE, priceText, problemsLock, projectAccess, viewRanges } from "./plans.js"; import { trialFor } from "./billing.js"; import { activeOwners, forgetTeam, membershipsOf } from "./team.js"; import { cancelForDeletion } from "./stripe.js"; @@ -1965,8 +1965,7 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E plan: sold.name, price: priceText(sold), days: (await trialFor(c.db, a.account.id, sold)) || null, - summary: sold.summary, - features: sold.features.filter((f) => f.available && !/^Everything in /i.test(f.text) && !/days free/i.test(f.text)).map((f) => f.text).slice(0, 8), + features: pitchLines(sold), start: `/account/billing?plan=${encodeURIComponent(sold.id)}&start=1`, }; } @@ -2170,7 +2169,7 @@ export function lockedJson(c: { cfg: AuthConfig; env: Env }, access?: { locked?: const url = `${c.cfg.origin}/account/billing?project=${encodeURIComponent(projectId)}`; const covers = (n: number | null | undefined) => (n === 1 ? "one app" : `${n ?? "its"} apps`); const error = access.locked === "free_projects" - ? `Free covers ${covers(FREE.limits.apps)}, and this one is past it: it needs ${PAID_PLANS[0]?.name ?? "a paid plan"}; start it at ${url}` + ? `since 13 November 2026 Free covers ${covers(FREE.limits.apps)}, your oldest, and this one is past it: it needs ${PAID_PLANS[0]?.name ?? "a paid plan"}; start it at ${url}` : `your plan covers ${covers(access.apps)}, and this one is past them: more apps come with Enterprise (${PLANS_FILE.contact}); see ${url}`; return json(402, { ok: false, error, locked: true, needs_plan: true, upgrade_url: url }); } diff --git a/telemetry-worker/src/billing-pages.ts b/telemetry-worker/src/billing-pages.ts index 5005fd6b..8083ec5c 100644 --- a/telemetry-worker/src/billing-pages.ts +++ b/telemetry-worker/src/billing-pages.ts @@ -8,7 +8,7 @@ // Talk to us, a mailto link, whatever the key. Which plans include something is said from billing/plans.json // (plansWith), never by name. -import { CLIENT_JS, ORG_CSS, orgRail, time } from "./account-pages.js"; +import { CLIENT_JS, ORG_CSS, orgRail, pitchList, time } from "./account-pages.js"; import type { Env } from "./collector.js"; import type { D1Like, Project, Range } from "./dashboard.js"; import { integrationRows, integrationsKey, KIND_NAMES, KINDS, maskConfig, openConfig, DATADOG_SITES, POSTHOG_HOSTS, type Kind } from "./forward.js"; @@ -28,6 +28,7 @@ import { RETIRED_PLAN_IDS, PLANS, PLANS_FILE, + pitchLines, planById, priceText, viewRanges, @@ -258,14 +259,13 @@ export function projectPlanPage(nonce: string, v: ProjectPlanView): string { const name = esc(v.project.name); // version 5 (2026-11-13): Pro is the account's, per developer, for up to 3 apps; every new app sees this page, a // paying account included (Continue with Pro), and an app past what the plan covers waits here: more apps are - // Enterprise's, by email (a plan sold by units of apps, apps_per_unit, adds them on Stripe instead) - const per = offer?.apps_per_unit ?? null; + // Enterprise's, by email const proFull = a.locked === "pro_apps"; const covered = !a.locked && a.source !== null; const status = a.source === "open" ? `This server gives every app everything in ${esc(a.plan.name)}.` : proFull - ? `Your ${esc(offer?.name ?? "paid")} plan covers ${per ? "" : "up to "}${a.apps ?? 0} apps, and ${name} is one more.${per ? ` Add ${per} more apps for ${esc(priceText(offer!).replace(/ for \d+ apps$/, ""))}.` : " More apps come with Enterprise, agreed by email."}` + ? `Your ${esc(offer?.name ?? "paid")} plan covers up to ${a.apps ?? 0} apps, and ${name} is one more. More apps come with Enterprise, agreed by email.` : paid ? v.sub.cancels ? `${name} is on ${esc(a.plan.name)} until ${day(v.sub.periodEnd)}. It is cancelled and will not renew; you can resume it in Manage billing.` @@ -275,31 +275,31 @@ export function projectPlanPage(nonce: string, v: ProjectPlanView): string { : a.source === "comp" ? `Your ${esc(a.plan.name)} plan, set up by GenClass, covers ${name}.` : a.locked - ? `Free covers ${FREE_APPS === 1 ? "one app" : `${FREE_APPS} apps`}, and ${name} is one more: it needs ${esc(offer?.name ?? "a paid plan")}.` - : `${name} is on Free.`; - const usage = v.usage && v.usage.limit !== null + ? `Since 13 November 2026 Free covers ${FREE_APPS === 1 ? "one app" : `${FREE_APPS} apps`}, your oldest, and ${name} is past it: it needs ${esc(offer?.name ?? "a paid plan")}.` + : (v.fromCli || v.isNew) && !v.chosen + ? `${name} is in your account.` + : `${name} is on Free.`; + // the month's live fixes, once there are some (a new app's "0 of 100" is one more line to read) + const usage = v.usage && v.usage.limit !== null && (v.usage.used > 0 || v.usage.observe_only) ? `

    ${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(""); + // the card's lines: the first few, the rest folded (the owner, 2026-11-13: "too much written in the plan details") + const features = offer ? pitchList(pitchLines(offer)) : ""; const action = covered ? `

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

    ` - : proFull && per - ? `

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

    ` - : proFull + : proFull ? `Email us about Enterprise

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

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

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

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

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

    ${esc(priceText(offer))}

    ${esc(offer.summary)}

      ${features}
    + ? `

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

    ${esc(priceText(offer))}

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

    Skip

    @@ -329,7 +329,7 @@ ${notice}${test}

    All your projects and their plans

    -`; +`; const script = ` (function(){ gcDropQuery(["project"]); @@ -359,7 +359,7 @@ ${notice}${test} 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" : ""); + location.href = "/account/apps/" + encodeURIComponent(cfg.project) + (cfg.isNew && !cfg.cli ? "#connect" : ""); }, function(e2){ err.textContent = gcWhy(e2); gcBusy(go, false); }); }); } @@ -369,22 +369,16 @@ ${notice}${test} var err = document.getElementById("portalerr"); err.textContent = ""; gcPost("/api/billing/portal", {}).then(function(j){ location.href = j.url; }, function(e2){ err.textContent = gcWhy(e2); }); }); - // a paying account goes on with its plan: the app's dashboard (its setup for a new one) + // a paying account goes on with its plan: the app's dashboard (its setup for one new on the website; the genclass + // command has set it up already, so it never lands on the website's setup with other modes ticked) var cont = document.querySelector("[data-continue]"); if (cont) cont.addEventListener("click", function(){ var err = document.getElementById("planerr"); err.textContent = ""; gcBusy(cont, true, "One moment..."); gcPost("/api/billing/projects/" + encodeURIComponent(cfg.project) + "/continue", {}).then(function(){ - location.href = "/account/apps/" + encodeURIComponent(cfg.project) + (cfg.isNew || cfg.cli ? "#connect" : ""); + location.href = "/account/apps/" + encodeURIComponent(cfg.project) + (cfg.isNew && !cfg.cli ? "#connect" : ""); }, function(e){ err.textContent = gcWhy(e); gcBusy(cont, false); }); }); - // more apps on Pro: the quantity is changed on Stripe's billing page, and this page waits for it - var add = document.querySelector("[data-addapps]"); - if (add) add.addEventListener("click", function(){ - var err = document.getElementById("planerr"); err.textContent = ""; - gcBusy(add, true, "Opening Stripe..."); - gcPost("/api/billing/portal", {}).then(function(j){ location.href = j.url; }, function(e){ err.textContent = gcWhy(e); gcBusy(add, false); }); - }); if (cfg.wait) { var tries = 0; var timer = setInterval(function(){ @@ -400,13 +394,14 @@ ${notice}${test} 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" }, + // the organization's plan in the header: an app past Pro's apps is locked, but its account is on Pro + org: { name: orgName(label), plan: a.locked === "pro_apps" && offer ? offer.name : 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 + ".planpage .sb-ph{text-align:center}.planpage .sb-ph>div{margin:0 auto}.planpage #plan{background:none;border:0;box-shadow:none;padding:0;display:flex;flex-direction:column;align-items:center;gap:0}.planpage #plan .intro{margin:20px auto 0;text-align:center}.planpage #plan .err:empty{display:none}.planpage .notice{max-width:560px;margin-left:auto;margin-right:auto}.planpage .acct-form{justify-content:center}.plan.big{width:100%;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:14px;font-size:11px;width:100%;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}.trialnote{margin:10px 0 0;font-size:13px;color:var(--ink3);text-align:center;width:100%}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}", + css: DASH_CSS + ORG_CSS + BILLING_CSS + ".planpage .sb-ph{text-align:center}.planpage .sb-ph>div{margin:0 auto}.planpage #plan{background:none;border:0;box-shadow:none;padding:0;display:flex;flex-direction:column;align-items:center;gap:0}.planpage #plan .intro{margin:20px auto 0;text-align:center}.planpage #plan .err:empty{display:none}.planpage .notice{max-width:440px;margin-left:auto;margin-right:auto}.planpage .acct-form{justify-content:center}.plan.big{width:100%;max-width:440px;padding:24px 24px 22px;gap:8px}.plan.big h3{font-size:24px;letter-spacing:-.01em}.plan.big .price{font-size:15px;font-weight:600;margin-top:2px}.plan.big ul{margin-top:6px;gap:6px}.plan.big li{font-size:13.5px}.plan.big .pmore{font-size:12.5px}.plan.big .pmore ul{margin-top:6px}.plan.big .act{padding-top:12px}.plan.big .act .btn{width:100%;justify-content:center;padding:11px 16px;font-size:14.5px}.free{margin-top:12px;font-size:11px;width:100%;max-width:440px;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}.trialnote{margin:10px 0 0;font-size:13px;color:var(--ink3);text-align:center;width:100%}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, }); diff --git a/telemetry-worker/src/billing.ts b/telemetry-worker/src/billing.ts index 1f75f76a..c2371a2e 100644 --- a/telemetry-worker/src/billing.ts +++ b/telemetry-worker/src/billing.ts @@ -373,21 +373,13 @@ async function checkout(c: C, f: Fetch): Promise { if (typeof body.project_id !== "string" || !body.project_id) return fail(400, `the ${plan.name} plan is bought for one project: send project_id`); return checkoutProject(c, f, a, plan, body.project_id); } - // a plan sold per seat: how many people (the owner included); one when the page sends none. A plan sold by units of - // apps (version 5, Pro): how many units, by default enough for the apps the account has + // a plan sold per seat (Pro since version 5): how many developers (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 (plan.apps_per_unit) { - const asked = body.quantity ?? body.seats; - const own = (await accountProjects(c, a.account.id)).filter((x) => x.account_id === a.account.id).length; - const n = asked === undefined ? Math.max(1, Math.ceil(own / plan.apps_per_unit)) : cleanSeats(asked); - if (n === null) return fail(400, `quantity is a whole number from 1 to ${MAX_SEATS}`); - seats = n; - } // started from an app's plan page: Stripe returns there const backTo = typeof body.project_id === "string" && (await ownedProject(c, a.account.id, body.project_id))?.id ? body.project_id : undefined; if (await overBudget(c.env.AUTH_LIMIT, "checkout", a.account.id)) return tooMany(); diff --git a/telemetry-worker/src/cli.ts b/telemetry-worker/src/cli.ts index 6f8f3659..13bc4e54 100644 --- a/telemetry-worker/src/cli.ts +++ b/telemetry-worker/src/cli.ts @@ -55,7 +55,7 @@ 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, accountProjectsAccess, canBuy, entitled, firstPaidWith, firstPlanWith, FREE, hasAnalytics, hasAsk, hasDashboard, hasProblems, PAID_PLANS, pickRange, priceText, problemsLock, projectAccess, viewRanges, type Access } from "./plans.js"; +import { accountAccess, accountBilling, accountProjectsAccess, better, canBuy, entitled, firstPaidWith, firstPlanWith, FREE, hasAnalytics, hasAsk, hasDashboard, hasProblems, PAID_PLANS, pickRange, priceText, problemsLock, projectAccess, viewRanges, type Access } from "./plans.js"; import { liveFixUsage } from "./usage.js"; import { appKey, askModel, byWeight, claimRun, hasModel, keepAnswer, lastAnswer, MAX_SUGGEST_BYTES, readDigest, suggestionLimits, usedToday, type PlanItem, type SuggestAnswer } from "./suggest.js"; import { activeOwners, createInvite, isTeamError, membershipsOf, teamOf } from "./team.js"; @@ -66,27 +66,46 @@ const FULL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/(?!\.{1,2}$)[A-Za-z0-9._-] // ------------------------------------------------------------------------- the genclass command is Pro's /** - * The genclass command works for this session (the owner's call, 2026-11-13: "let's make the entire CLI paid"): a - * browser session always; a CLI session when the account has a plan of its own (paid, in its trial, or GenClass's - * grant) or an active place in a team that has one, and wherever nothing is sold to the account (no plan on sale, no - * Stripe key, a test key and an account not on its list). PLAN_GATES open gives every account the grant. + * The plan an account works under: the best of its own (paid, its trial, GenClass's grant) and those of the teams it + * has an active place in. A member uses its team's plan for what the command gives it (suggestions a day and shown, + * genclass ask, the plan it names), so a paying team's member is never told to buy Pro again. `team`: the plan is a + * team's, not the account's own. */ -export async function cliPaid(c: C, a: Auth): Promise { - if (a.via !== "bearer" || !PAID_PLANS.length || !canBuy(c.env, a.account)) return true; - if ((await accountAccess(c.db, a.account.id, c.env, c.now)).source !== null) return true; - for (const owner of await activeOwners(c.db, c.env, a.account.id, c.now)) { - if ((await accountAccess(c.db, owner, c.env, c.now)).source !== null) return true; +export async function workingAccess(c: C, accountId: string): Promise<{ access: Access; team: boolean }> { + const own = await accountAccess(c.db, accountId, c.env, c.now); + let best = own; + for (const owner of await activeOwners(c.db, c.env, accountId, c.now)) { + const a = await accountAccess(c.db, owner, c.env, c.now); + if (a.source !== null && better(best, a) === a && a !== best) best = a; } - return false; + return { access: best, team: best !== own }; } /** - * What the command says about itself (`cli` in the overview and the account): `paid`, and for an account without - * Pro the plan, its price, the trial it has left and the page that opens its checkout at once (the account's, so a - * new account with no app goes straight to Stripe). + * Why the genclass command works for this session, or null when it does not (the owner's call, 2026-11-13: "let's + * make the entire CLI paid"): "plan", the account's own plan (paid, in its trial, GenClass's grant); "team", an + * active place in a team that has one; "open", nothing is sold to this session here (a browser session, no plan on + * sale, no Stripe key, a test key and an account not on its list, PLAN_GATES open). */ -async function cliJson(c: C, a: Auth): Promise<{ paid: true } | { paid: false; plan: string; price: string; trial_days: number | null; checkout_url: string }> { - if (await cliPaid(c, a)) return { paid: true }; +async function cliVia(c: C, a: Auth): Promise<"plan" | "team" | "open" | null> { + if (a.via !== "bearer" || !PAID_PLANS.length || !canBuy(c.env, a.account)) return "open"; + const w = await workingAccess(c, a.account.id); + return w.access.source === null ? null : w.access.source === "open" ? "open" : w.team ? "team" : "plan"; +} + +/** The genclass command works for this session (cliVia). */ +export async function cliPaid(c: C, a: Auth): Promise { + return (await cliVia(c, a)) !== null; +} + +/** + * What the command says about itself (`cli` in the overview and the account): `paid` with `via` (cliVia), and for + * an account without Pro the plan, its price, the trial it has left and the page that opens its checkout at once (the + * account's, so a new account with no app goes straight to Stripe). + */ +async function cliJson(c: C, a: Auth): Promise<{ paid: true; via: "plan" | "team" | "open" } | { paid: false; plan: string; price: string; trial_days: number | null; checkout_url: string }> { + const via = await cliVia(c, a); + if (via !== null) return { paid: true, via }; const plan = PAID_PLANS[0]!; return { paid: false, plan: plan.name, price: priceText(plan), trial_days: (await trialFor(c.db, a.account.id, plan)) || null, checkout_url: `${c.cfg.origin}/account/billing?plan=${plan.id}&start=1` }; } @@ -97,12 +116,13 @@ const PRO_ROUTE_RE = /^\/api\/cli\/(?:github|suggest|app|team\/invite|connection async function overview(c: C): Promise { const a = await authenticate(c); if (!a) return fail(401, "not signed in"); - const access = await accountAccess(c.db, a.account.id, c.env, c.now); + // a team's member works under the team's plan (workingAccess): its limits, its name, no offer of a second Pro + const { access, team } = await workingAccess(c, a.account.id); const lim = suggestionLimits(access.plan); const gh = githubApp(c.env); return json(200, { account: accountJson(a.account), - plan: { id: access.plan.id, name: access.plan.name }, + plan: { id: access.plan.id, name: access.plan.name, ...(team ? { via: "team" } : {}) }, 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 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 }, @@ -143,14 +163,17 @@ async function offerFor(c: C, account: { id: string; email: string | null }, acc if (plan.per_project === true && projects ? !open.length : access.plan.id !== FREE.id || access.source !== null) return null; // the page of the account's first app with no plan: Pro is bought there, for the account (version 5) const one = open.length ? open[0]!.row.id! : null; + const trialDays = (await trialFor(c.db, account.id, plan)) || 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), + // the plan's own "N days free" line only while the account still has the trial + features: plan.features.filter((f) => f.available && !/^Everything in /i.test(f.text) && (trialDays || !/days free/i.test(f.text))).map((f) => f.text).slice(0, 12), checkout_url: one ? `${c.cfg.origin}/account/billing?project=${encodeURIComponent(one)}&from=cli` : `${c.cfg.origin}/account/billing?plan=${plan.id}&start=1`, - trial_days: (await trialFor(c.db, account.id, plan)) || null, - apps_per_unit: plan.apps_per_unit ?? null, + trial_days: trialDays, + // a plan sold by units of apps (a design never sold): always null, kept because the genclass command reads it + apps_per_unit: null, free: { name: FREE.name, apps: FREE.limits.apps, history_days: FREE.limits.history_days, suggestions_shown: suggestionLimits(FREE).shown, live_fixes: FREE.limits.live_fixes_per_month ?? null }, }; } @@ -201,7 +224,8 @@ async function suggest(c: C): Promise { if (body instanceof Response) return body; const digest = readDigest(body); if (typeof digest === "string") return fail(400, digest); - const access = await accountAccess(c.db, a.account.id, c.env, c.now); + // a team's member gets its team's plan (workingAccess) + const { access } = await workingAccess(c, a.account.id); const lim = suggestionLimits(access.plan); // Free (a plan that shows part of the answer) is shown the answer it got the first time for this app, again, until // the account pays (the owner's call, 2026-11-12): no new model run, the same first item and the same counts @@ -285,13 +309,17 @@ async function account(c: C): Promise { const b = await accountBilling(c.db, a.account.id, c.env); const t = await teamOf(c.db, c.env, a.account.id, c.now); const memberships = await membershipsOf(c.db, c.env, a.account.id, c.now); - const lim = suggestionLimits(access.plan); + // `plan` is the account's own (what it pays for); a member works under its team's (team_plan), suggestions included + const work = await workingAccess(c, a.account.id); + const lim = suggestionLimits(work.access.plan); const apps = await accountProjects(c, a.account.id); return json(200, { account: accountJson(a.account), 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, + // the plan the account works under through a team when it is better than its own (workingAccess), else null + team_plan: work.team ? { id: work.access.plan.id, name: work.access.plan.name } : null, 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") }, @@ -304,7 +332,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: await offerFor(c, a.account, access, await accountProjectsAccess(c.db, a.account.id, c.env, c.now)), + offer: await offerFor(c, a.account, work.access, await accountProjectsAccess(c.db, a.account.id, c.env, c.now)), cli: await cliJson(c, a), }); } @@ -376,7 +404,8 @@ async function ask(c: C, f: Fetch): Promise { const lock = problemsLock(`${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}`); return json(403, { ok: false, error: `asking about your app comes with ${lock.plan ?? "a paid plan"}`, locked: true, needs_plan: true, plan: lock.plan, upgrade_url: lock.upgrade_url, trial_days: lock.trial_days, price: lock.price }); } - const mine = await accountAccess(c.db, a.account.id, c.env, c.now); + // the runs a day are the asker's, under the plan it works under (its team's for a member: workingAccess) + const mine = (await workingAccess(c, a.account.id)).access; const lim = suggestionLimits(mine.plan); const left = async (): Promise => Math.max(0, lim.per_day - (await usedToday(c.db, a.account.id, c.now))); const none = async (why: "no-model" | "limit" | "failed"): Promise => diff --git a/telemetry-worker/src/plans.ts b/telemetry-worker/src/plans.ts index 51ee940e..921ca2c9 100644 --- a/telemetry-worker/src/plans.ts +++ b/telemetry-worker/src/plans.ts @@ -76,11 +76,6 @@ export interface Plan { per_project?: boolean; /** Days free at Checkout before the first payment (Pro: 3), once per account; left out: no trial. */ trial_days?: number; - /** - * Version 5: the apps one unit of the subscription covers (Pro: 3). The quantity is the units (accounts.seats), so - * the account's first apps_per_unit x quantity apps have the plan; a later one waits for more (coverage). - */ - apps_per_unit?: number; /** * `seats`: the people a plan that is not per_seat holds, the owner included (null or left out: no limit). `apps` * on Free: how many of an account's projects it covers (a later one is locked until it has a plan). @@ -206,7 +201,15 @@ export function priceText(plan: Plan): string { const c = plan.price_usd_cents.monthly; if (c === null) return "By agreement"; if (!c) return "$0"; - return `$${c % 100 ? (c / 100).toFixed(2) : String(c / 100)}${plan.per_project === true ? " / project" : plan.per_seat === true ? " / developer" : ""} / month${plan.apps_per_unit ? ` for ${plan.apps_per_unit} apps` : ""}`; + return `$${c % 100 ? (c / 100).toFixed(2) : String(c / 100)}${plan.per_project === true ? " / project" : plan.per_seat === true ? " / developer" : ""} / month`; +} + +/** + * A plan's feature lines for a pitch (an app's plan page, a new account's trial screen): the available ones, without + * "Everything in ..." and the trial's own line (the button and the note under it say that). + */ +export function pitchLines(plan: Plan): string[] { + return plan.features.filter((f) => f.available && !/^Everything in /i.test(f.text) && !/days free/i.test(f.text)).map((f) => f.text); } /** The most seats one Checkout sells and one account holds on a per_seat plan; a larger team is agreed by email. */ @@ -285,8 +288,8 @@ export interface Access { /** How many people the account holds now, its owner included (seatsOf); null: no limit. */ seats: number | null; /** - * An app past the ones its account's plan covers (coverage): on Free past Free's apps, or on Pro past 3 x the units - * paid for. No dashboard until the account's plan covers it. + * An app past the ones its account's plan covers (coverage): on Free past Free's one app, on Pro past its 3 (or the + * apps a subscription from before version 5 kept). No dashboard until the account's plan covers it. */ locked?: "free_projects" | "pro_apps"; /** How many apps the account's plan covers (coverage); null: no limit. */ @@ -311,11 +314,13 @@ export const OPEN_ACCESS: Access = { plan: GRANT_PLAN, source: "open", seats: nu /** * How many people an account on `plan` holds, its owner included (null: no limit). A paid per_seat plan: the seats - * paid for, at least 1. Any other plan, and every grant: the plan's own limits.seats (a grant of a per_seat plan has - * no seat count, so no limit). + * paid for, or no limit when they are not known: a subscription bought per project under version 4 (the whole team + * included, so it never had a seat count) and an account row whose seats were not written (a rollback, a webhook + * still to come); the webhook writes them for every subscription bought per seat. Any other plan, and every grant: + * the plan's own limits.seats (a grant of a per_seat plan has 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, cleanSeats(paidSeats) ?? 1) : null; + if (plan.per_seat === true) return source === "paid" ? cleanSeats(paidSeats) : null; return plan.limits.seats ?? null; } @@ -345,15 +350,21 @@ 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) }; + // a paid plan wins a grant of the same plan (Manage billing, the subscription's state), with the grant's room: no + // seat limit (the grant never had one); coverage gives it every app too + if (paid.id !== "free" && (!comp || rank(paid) >= rank(grant))) return { plan: paid, source: "paid", seats: comp && rank(grant) === rank(paid) ? null : seatsOf(paid, "paid", row.seats) }; if (comp) return { plan: grant, source: "comp", seats: seatsOf(grant, "comp", null) }; return NO_ACCESS; } -/** The better of two accesses: the plan listed later in plans.json, a paid one before a grant of the same plan. */ +/** + * The better of two accesses: the plan listed later in plans.json, a paid one before a grant of the same plan, and of + * two of the same plan and source the one with more seats (no limit first). + */ 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; + if (a.source !== b.source) return a.source === "paid" || b.source !== "paid" ? a : b; + return a.seats === null || (b.seats !== null && a.seats >= b.seats) ? a : b; } /** The columns a project's own plan is read from (migration 0016). */ @@ -379,7 +390,10 @@ export function accountWide(row: AccessRow | null | undefined, mode: StripeMode 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. */ +/** + * A project's own access: its paid plan (as the current Stripe key sees it) or its grant (accessOf), else Free. A + * subscription bought per project (version 4) included the whole team: no seat limit (seatsOf). + */ 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); @@ -404,8 +418,8 @@ export async function accountPlan(db: D1Like, accountId: string, env: Env, now: /** This access opens dashboards (every plan in billing/plans.json today), unless the project waits for a plan. */ export const hasDashboard = (a: Access): boolean => entitled(a.plan, "dashboard") && !a.locked; -/** A plan sold by quantity: seats (per_seat) or units of apps (apps_per_unit). */ -export const byQuantity = (plan: Plan): boolean => plan.per_seat === true || (Number.isInteger(plan.apps_per_unit) && plan.apps_per_unit! > 0); +/** A plan sold by quantity: seats (per_seat). */ +export const byQuantity = (plan: Plan): boolean => plan.per_seat === true; /** 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"); @@ -424,43 +438,61 @@ export const firstPaidWith = (e: "problems" | "ask"): Plan | null => PAID_PLANS. /** What an account's plan covers: its access, how many of its apps (null: all) and its apps, oldest first. */ export interface Coverage { access: Access; + /** How many of the apps that do not pay for themselves the plan covers, oldest first (null: all). */ cap: number | null; rows: ProjectPlanRow[]; - /** The tokens of the apps with a subscription of their own (bought per project under version 4): always covered. */ + /** + * The tokens of the apps with a subscription of their own (bought per project under version 4): always covered, and + * not counted in the cap. + */ ownPaid?: Set; } /** * The account's plan and how many apps it covers (billing/plans.json version 5): the best of its own subscription or * grant and every app's (a subscription bought per project under version 4, a grant copied to an app). A paid plan - * sold by units (apps_per_unit) covers apps_per_unit x the units: the account's quantity (accounts.seats) plus one for - * each app's own subscription. A grant covers every app, Free its limits.apps. + * covers limits.apps of the apps that do not pay for themselves (Pro: 3), oldest first, or as many as the account had + * when version 5 came while that same subscription lasts (accounts.apps_kept, migration 0021: what was bought before + * 13 November 2026 keeps covering the apps it covered). An app with a subscription of its own is always covered and + * not counted. A grant covers every app, a paid plan under a grant of the same plan too; Free covers its limits.apps. */ export async function coverage(db: D1Like, accountId: string, mode: StripeMode | null, now: Date): Promise { - const acct = await db.prepare(`SELECT ${ACCESS_COLUMNS} FROM accounts WHERE id = ?`).bind(accountId).first(); + const acct = await db.prepare(`SELECT ${ACCESS_COLUMNS}, apps_kept FROM accounts WHERE id = ?`).bind(accountId).first(); const rows = (await db.prepare(`SELECT ${PROJECT_PLAN_COLUMNS} FROM projects WHERE account_id = ? ORDER BY created, rowid`).bind(accountId).all()).results ?? []; - let best = accessOf(acct, mode, now); - let units = best.source === "paid" ? Math.max(1, cleanSeats(acct?.seats) ?? 1) : 0; + const own = accessOf(acct, mode, now); + let best = own; + // the best plan a grant gives here (the account's or one copied to an app), which covers every app + let granted: Plan | null = acct && future(acct.comp_until, now) ? grantPlan(acct.comp_plan) : null; const ownPaid = new Set(); for (const r of rows) { - const own = projectOwnAccess(r, mode, now); - if (own.source === "paid") { - units++; - ownPaid.add(r.token); - } - best = better(best, own); + const a = projectOwnAccess(r, mode, now); + if (a.source === "paid") ownPaid.add(r.token); + if (a.source === "comp" && (!granted || rank(a.plan) > rank(granted))) granted = a.plan; + best = better(best, a); } - const per = best.plan.apps_per_unit; - const cap = best.source === "comp" || best.source === "open" ? null : best.source === "paid" ? (per ? per * Math.max(1, units) : (best.plan.limits.apps ?? null)) : (FREE.limits.apps ?? null); + const limit = best.plan.limits.apps ?? null; + const kept = own.source === "paid" ? Math.max(0, Math.floor(Number(acct?.apps_kept) || 0)) : 0; + const cap = + best.source === "comp" || best.source === "open" || (granted && rank(granted) >= rank(best.plan)) + ? null + : best.source === "paid" + ? limit === null ? null : Math.max(limit, kept) + : (FREE.limits.apps ?? null); return { access: { ...best, apps: cap }, cap, rows, ownPaid }; } -/** One app's access under its account's coverage: the plan when the app is among the first `cap`, else locked. */ +/** + * One app's access under its account's coverage: the plan when the app pays for itself or is among the first `cap` + * of the others, else locked. + */ export function accessIn(cov: Coverage, project: { token?: string; id?: string | null }): Access { const i = cov.rows.findIndex((r) => (project.token ? r.token === project.token : r.id === project.id)); - if (i < 0 || cov.cap === null || i < cov.cap) return cov.access; - // an app still paying for itself (a version 4 subscription) is never locked, wherever it is in the list + if (i < 0 || cov.cap === null) return cov.access; + // an app still paying for itself (a version 4 subscription) is never locked, and the others are counted without it if (cov.ownPaid?.has(cov.rows[i]!.token)) return cov.access; + let before = 0; + for (let j = 0; j < i; j++) if (!cov.ownPaid?.has(cov.rows[j]!.token)) before++; + if (before < cov.cap) return cov.access; return { ...NO_ACCESS, apps: cov.cap, locked: cov.access.source === null ? "free_projects" : "pro_apps" }; } diff --git a/telemetry-worker/src/stripe.ts b/telemetry-worker/src/stripe.ts index 0092c895..8f52f0fd 100644 --- a/telemetry-worker/src/stripe.ts +++ b/telemetry-worker/src/stripe.ts @@ -411,8 +411,6 @@ export async function checkoutSession( const lookup = o.plan.stripe_lookup_keys.monthly; if (!lookup) throw new StripeError(`the ${o.plan.name} plan has no monthly price`, 400); // a plan sold per seat: the quantity is the seats asked for, and the buyer can still change it on Stripe's page - // a plan sold by quantity: seats (per_seat) or units of apps (apps_per_unit, Pro since version 5); the buyer can still - // change it on Stripe's page const perSeat = byQuantity(o.plan); const seats = perSeat ? (cleanSeats(o.seats ?? 1) ?? 1) : 1; if (o.switchFrom !== undefined && !idOf(o.switchFrom)) throw new StripeError("not a subscription id", 400); @@ -1078,11 +1076,13 @@ export async function applySubscription(x: EventContext, account: BillingAccount if (s.priced === null) console.log(`stripe webhook: ${String(event?.type)} ${String(event?.id)}: subscription ${subId} no longer has a GenClass price; plan set to free`); await x.db .prepare( + // apps_kept (migration 0021, what a subscription from before version 5 covered) ends with that subscription: when + // it ends, or another one replaces it (SET reads the row as it was) "UPDATE accounts SET plan = ?, subscription_id = ?, subscription_status = ?, past_due_since = ?, current_period_end = ?, cancel_at_period_end = ?, " + - "stripe_mode = ?, seats = ? WHERE id = ? AND stripe_customer_id = ?", + "stripe_mode = ?, seats = ?, apps_kept = CASE WHEN ? = 'free' OR subscription_id IS NOT ? THEN NULL ELSE apps_kept END WHERE id = ? AND stripe_customer_id = ?", ) // the seats follow the subscription like the plan: a plan that is not sold per seat, or that ended, has none - .bind(s.plan, subId, s.status, pastDueSince, s.periodEnd, s.cancelAtPeriodEnd ? 1 : 0, stripeMode(x.env), s.plan === "free" ? null : s.seats, account.id, customer) + .bind(s.plan, subId, s.status, pastDueSince, s.periodEnd, s.cancelAtPeriodEnd ? 1 : 0, stripeMode(x.env), s.plan === "free" ? null : s.seats, s.plan, subId, account.id, customer) .run(); return { status: "applied", ours: true, account: account.id, plan: s.plan }; } diff --git a/telemetry-worker/src/team.ts b/telemetry-worker/src/team.ts index 47e8e27b..8ec3c81e 100644 --- a/telemetry-worker/src/team.ts +++ b/telemetry-worker/src/team.ts @@ -18,7 +18,7 @@ import type { Env } from "./collector.js"; import { randomBase62, sha256Hex, type D1Like } from "./dashboard.js"; -import { accountAccess, PAID_PLANS, type Access } from "./plans.js"; +import { accountAccess, PAID_PLANS, priceText, type Access } from "./plans.js"; /** The plans on sale that hold more than one person (per seat, or with no limit on people), for the upgrade message. */ const TEAM_PLANS = PAID_PLANS.filter((p) => p.per_seat === true || p.limits.seats === null) @@ -122,10 +122,14 @@ export async function canRead(db: D1Like, env: Env, memberId: string, ownerId: s return (await activeOwners(db, env, memberId, now)).includes(ownerId); } +// a plan bought per seat with one seat (the owner's) is not a plan for one person: its seats are added in Manage billing +const perSeatPaid = (t: Team): boolean => t.access.source === "paid" && t.access.plan.per_seat === true; const fullError = (t: Team): TeamError => - t.seats !== null && t.seats <= 1 + t.seats !== null && t.seats <= 1 && !perSeatPaid(t) ? { status: 403, upgrade: true, error: `the ${t.access.plan.name} plan is for one person; members come with ${TEAM_PLANS || "a paid plan"}` } - : { status: 409, error: `every seat is taken (${t.seats} of ${t.seats}): add a seat in Manage billing, remove a member or revoke an invite` }; + : t.seats !== null && t.seats <= 1 + ? { status: 409, error: `your ${t.access.plan.name} plan has one seat, yours: add a seat for each member in Manage billing (${priceText(t.access.plan)})` } + : { status: 409, error: `every seat is taken (${t.seats} of ${t.seats}): add a seat in Manage billing, remove a member or revoke an invite` }; /** * A new invite link for the account's team, when its plan has a seat free for it (members and unexpired invites @@ -166,7 +170,7 @@ export async function joinTeam(db: D1Like, env: Env, memberId: string, code: str if (t.members.some((m) => m.id === memberId)) return { ownerId: inv.account_id, already: true }; if (t.members.length >= room(t.seats)) { return t.seats !== null && t.seats <= 1 - ? { status: 409, error: "this team's plan has no seat for members now; ask its owner" } + ? { status: 409, error: perSeatPaid(t) ? "this team's plan has one seat, its owner's: ask its owner to add one for you in Manage billing" : "this team's plan has no seat for members now; ask its owner" } : { status: 409, error: "every seat of this team is taken; ask its owner to add one" }; } await db.batch([ diff --git a/telemetry-worker/test/cli-account.test.ts b/telemetry-worker/test/cli-account.test.ts index 10e18fd3..b8e2d34a 100644 --- a/telemetry-worker/test/cli-account.test.ts +++ b/telemetry-worker/test/cli-account.test.ts @@ -317,10 +317,11 @@ describe("genclass command: POST /api/cli/ask (src/ask.ts)", () => { expect((await send(env, "POST", "/api/cli/ask", me.bearer, { id: app.id, question: 7 }, model.fetch)).status).toBe(400); const other = await cliUser(env, "bob@example.com"); expect((await send(env, "POST", "/api/cli/ask", other.bearer, { id: app.id, question: "How is their app?" }, model.fetch)).status).toBe(404); - // the app on Pro (a version-4 subscription of its own gives the account Pro, which covers up to 3 apps): the model - // answers for it and for the account's next app; a 4th app is past Pro's 3 and stays locked + // the app on Pro (a version-4 subscription of its own gives the account Pro, which covers 3 more apps): the model + // answers for it and for the account's next app; a 5th app is past Pro's 3 and stays locked const blog = await createApp(env, me.cookie, "Blog"); await createApp(env, me.cookie, "Docs"); + await createApp(env, me.cookie, "Fourth"); const extra = await createApp(env, me.cookie, "Extra"); pro(sql, app.id); expect(await json(send(env, "POST", "/api/cli/ask", me.bearer, { id: app.id, question: "How is my app?" }, model.fetch))).toMatchObject({ ok: true, answer: "An answer." }); @@ -527,7 +528,7 @@ describe("the genclass command is Pro's (src/cli.ts cliPaid; the owner's call of expect(r.status).not.toBe(402); expect(r.status).toBe(200); expect((await call(env, "GET", "/api/cli/overview", { cookie: me.cookie, origin: null })).status).toBe(200); - expect((await json(call(env, "GET", "/api/cli/overview", { cookie: me.cookie, origin: null }))).cli).toEqual({ paid: true }); + expect((await json(call(env, "GET", "/api/cli/overview", { cookie: me.cookie, origin: null }))).cli).toEqual({ paid: true, via: "open" }); }); it("Pro (active or in its trial), a version-4 project subscription and GenClass's grant open the command", async () => { @@ -535,8 +536,8 @@ describe("the genclass command is Pro's (src/cli.ts cliPaid; the owner's call of expect(await appStatus()).toBe(402); team(db, me.account.id); expect(await appStatus()).toBe(200); - expect((await json(get(env, "/api/cli/overview", me.bearer))).cli).toEqual({ paid: true }); - expect((await json(get(env, "/api/cli/account", me.bearer))).cli).toEqual({ paid: true }); + expect((await json(get(env, "/api/cli/overview", me.bearer))).cli).toEqual({ paid: true, via: "plan" }); + expect((await json(get(env, "/api/cli/account", me.bearer))).cli).toEqual({ paid: true, via: "plan" }); setPlan(db, me.account.id, "team", { subscription_status: "trialing" }); expect(await appStatus()).toBe(200); // a version-4 subscription of one app @@ -571,7 +572,16 @@ describe("the genclass command is Pro's (src/cli.ts cliPaid; the owner's call of expect((await json(get(t.env, "/api/cli/account", bob.bearer))).plan).toMatchObject({ id: "free" }); expect((await get(t.env, `/api/cli/app?id=${mine.id}`, bob.bearer)).status).toBe(200); expect((await get(t.env, `/api/cli/app?id=${shop.id}`, bob.bearer)).status).toBe(200); - expect((await json(get(t.env, "/api/cli/overview", bob.bearer))).cli).toEqual({ paid: true }); + expect((await json(get(t.env, "/api/cli/overview", bob.bearer))).cli).toEqual({ paid: true, via: "team" }); + // and he works under the team's plan: its name, its suggestions, no offer of a second Pro (cli.ts workingAccess) + const seen = await json(get(t.env, "/api/cli/overview", bob.bearer)); + expect(seen.plan).toEqual({ id: "team", name: "Pro", via: "team" }); + expect(seen.suggestions).toMatchObject({ per_day: 30, shown: null }); + expect(seen.offer).toBeNull(); + const acct = await json(get(t.env, "/api/cli/account", bob.bearer)); + expect(acct.team_plan).toEqual({ id: "team", name: "Pro" }); + expect(acct.limits).toMatchObject({ suggestions_per_day: 30, suggestions_shown: null }); + expect(acct.offer).toBeNull(); // the owner down to one seat: bob is past it, and so gated again team(t.sql, owner.account.id, 1); expect((await get(t.env, `/api/cli/app?id=${mine.id}`, bob.bearer)).status).toBe(402); @@ -585,7 +595,7 @@ describe("the genclass command is Pro's (src/cli.ts cliPaid; the owner's call of it("nothing is gated where nothing is sold to the account: no Stripe key, a test key it is not listed for, PLAN_GATES open", async () => { const none = await freeUser({ INTEGRATIONS_KEY: SEAL }); expect(await none.appStatus()).toBe(200); - expect((await json(get(none.env, "/api/cli/overview", none.me.bearer))).cli).toEqual({ paid: true }); + expect((await json(get(none.env, "/api/cli/overview", none.me.bearer))).cli).toEqual({ paid: true, via: "open" }); const test = await freeUser({ STRIPE_SECRET_KEY: "sk_test_fake" }); expect(await test.appStatus()).toBe(200); // the test key sells to the accounts on its list: there the command is Pro's @@ -593,6 +603,6 @@ describe("the genclass command is Pro's (src/cli.ts cliPaid; the owner's call of expect(await listed.appStatus()).toBe(402); const open = await freeUser({ ...LIVE, PLAN_GATES: "open" }); expect(await open.appStatus()).toBe(200); - expect((await json(get(open.env, "/api/cli/account", open.me.bearer))).cli).toEqual({ paid: true }); + expect((await json(get(open.env, "/api/cli/account", open.me.bearer))).cli).toEqual({ paid: true, via: "open" }); }); }); diff --git a/telemetry-worker/test/plans.test.ts b/telemetry-worker/test/plans.test.ts index 56191cbf..a95fc393 100644 --- a/telemetry-worker/test/plans.test.ts +++ b/telemetry-worker/test/plans.test.ts @@ -159,17 +159,19 @@ describe("billing/plans.json: the one source of plans", () => { expect(accessOf(row({ plan: "individual", comp_until: later }), "live", at)).toMatchObject({ source: "comp", plan: { id: "enterprise" } }); expect(accessOf(row({ plan: "pro" }), "live", at)).toEqual(none); expect(accessOf(row({ plan: "custom", stripe_mode: "live" }), "live", at)).toEqual(none); - // Pro, paid (per developer since version 5): the seats paid for, at least 1 + // Pro, paid (per developer since version 5): the seats paid for; not known (a row the webhook has not written + // seats to, after a rollback): no seat limit, never a team cut to one expect(accessOf(row({ plan: "team", stripe_mode: "live", seats: 4 }), "live", at)).toMatchObject({ source: "paid", plan: { id: "team" }, seats: 4 }); - expect(accessOf(row({ plan: "team", stripe_mode: "live" }), "live", at)).toMatchObject({ source: "paid", plan: { id: "team" }, seats: 1 }); + 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", seats: 4 }); // a grant names its plan (comp_plan); unknown, empty or free: plans.json grant_plan. A grant has no seat count expect(accessOf(row({ comp_until: later, comp_plan: "team" }), "live", at)).toMatchObject({ source: "comp", plan: { id: "team" }, seats: null }); for (const comp_plan of [null, "", "free", "pro"]) expect(accessOf(row({ comp_until: later, comp_plan }), "live", at), String(comp_plan)).toMatchObject({ source: "comp", plan: { id: "enterprise" } }); - // both: the plan listed later applies (a Team grant does not lower a paid Team's seats; an Enterprise grant wins) - expect(accessOf(row({ plan: "team", stripe_mode: "live", seats: 2, comp_until: later, comp_plan: "team" }), "live", at)).toMatchObject({ source: "paid", seats: 2 }); + // both: the plan listed later applies (an Enterprise grant wins); a grant of the same plan keeps the paid one, with + // the grant's room (no seat limit) + 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 }); @@ -181,9 +183,10 @@ describe("billing/plans.json: the one source of plans", () => { for (const bad of [0, -1, 201, 1.5, "3", null, undefined, NaN, Infinity]) expect(cleanSeats(bad), String(bad)).toBeNull(); const [free, team, enterprise] = PLANS; expect(seatsOf(free!, null, null)).toBe(1); - // Pro is per developer since version 5: paid, what was paid for (at least 1); granted, no seat count + // Pro is per developer since version 5: paid, what was paid for (not known: no limit); granted, no seat count expect(seatsOf(team!, "paid", 5)).toBe(5); - expect(seatsOf(team!, "paid", 0)).toBe(1); + expect(seatsOf(team!, "paid", 0)).toBeNull(); + expect(seatsOf(team!, "paid", null)).toBeNull(); expect(seatsOf(team!, "comp", 5)).toBeNull(); // a plan not sold per seat holds its own limit expect(seatsOf({ ...team!, per_seat: false }, "paid", 5)).toBeNull(); diff --git a/telemetry-worker/test/project-plans.test.ts b/telemetry-worker/test/project-plans.test.ts index b6d14551..a5c17174 100644 --- a/telemetry-worker/test/project-plans.test.ts +++ b/telemetry-worker/test/project-plans.test.ts @@ -5,15 +5,16 @@ // GenClass's grants cover every app. Coverage, Checkout and its trial, the webhook, every app's plan page (Continue // with Pro, the trial, Email us about Enterprise), the website's redirect to it, the live-fix limit at ingest, and // what a project that is paid for cannot lose. +import { readFileSync } from "node:fs"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { resetTokenCache } from "../src/dashboard.js"; import { ownerOf, resetOwnerCache } from "../src/paid.js"; -import { accessIn, accountProjectsAccess, byQuantity, coverage, FREE, priceText, PLANS } from "../src/plans.js"; +import { accessIn, accountAccess, accountProjectsAccess, byQuantity, coverage, FREE, pitchLines, priceText, PLANS } from "../src/plans.js"; import { FREE_REASONS, trialFor } from "../src/billing.js"; import { FREE_REASONS_SHOWN } from "../src/billing-pages.js"; import { resetStripeCache } from "../src/stripe.js"; import { monthOf, OBSERVE_HEADER, resetUsageCache } from "../src/usage.js"; -import { call, createApp, deliver, fakeStripe, GENCLASS_PORTAL, now, price, resetClock, sendBatch, setPlan, setup, signedIn, stripeEvent, subscription, WEBHOOK_SECRET } from "./account-helpers.js"; +import { call, cliToken, createApp, deliver, fakeStripe, GENCLASS_PORTAL, now, price, resetClock, sendBatch, setPlan, setup, signedIn, stripeEvent, subscription, WEBHOOK_SECRET } from "./account-helpers.js"; const LIVE = { STRIPE_SECRET_KEY: "sk_live_fake", STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET, STRIPE_PORTAL_CONFIGURATION: GENCLASS_PORTAL }; const text = async (r: Promise | Response) => (await r).text(); @@ -118,26 +119,29 @@ describe("coverage: what the account's plan covers (src/plans.ts coverage, acces expect(await plansOf(t)).toEqual(["free", "free_projects", "free_projects", "free_projects"]); }); - it("a version-4 subscription of one project gives the account Pro and its 3 apps; two of them still cover 3", async () => { - const t = await withApps(["Shop", "Blog", "Docs", "Extra"]); + it("a version-4 subscription of one project gives the account Pro for 3 more apps: an app that pays for itself is not counted", async () => { + const t = await withApps(["Shop", "Blog", "Docs", "Extra", "Fifth"]); projectPaid(t.sql, t.apps[1]!.id); - expect(await plansOf(t)).toEqual(["team:paid", "team:paid", "team:paid", "pro_apps"]); + // Blog pays for itself; Shop, Docs and Extra are Pro's 3 (under version 4 they were Free's 3: nothing is lost) + expect(await plansOf(t)).toEqual(["team:paid", "team:paid", "team:paid", "team:paid", "pro_apps"]); expect((await coverage(t.sql.api, t.account.id, "live", now())).cap).toBe(3); projectPaid(t.sql, t.apps[2]!.id); expect((await coverage(t.sql.api, t.account.id, "live", now())).cap).toBe(3); - expect(await plansOf(t)).toEqual(["team:paid", "team:paid", "team:paid", "pro_apps"]); - // an app that still pays for itself is never locked, wherever it is in the list: a 4th with its own subscription - projectPaid(t.sql, t.apps[3]!.id); - expect(await plansOf(t)).toEqual(["team:paid", "team:paid", "team:paid", "team:paid"]); - t.sql.db.prepare("UPDATE projects SET plan = NULL, subscription_id = NULL, subscription_status = NULL WHERE id = ?").run(t.apps[3]!.id); - expect(await plansOf(t)).toEqual(["team:paid", "team:paid", "team:paid", "pro_apps"]); - // with the account's own subscription as well: still 3 + expect(await plansOf(t)).toEqual(["team:paid", "team:paid", "team:paid", "team:paid", "team:paid"]); + t.sql.db.prepare("UPDATE projects SET plan = NULL, subscription_id = NULL, subscription_status = NULL WHERE id = ?").run(t.apps[2]!.id); + expect(await plansOf(t)).toEqual(["team:paid", "team:paid", "team:paid", "team:paid", "pro_apps"]); + // an app that still pays for itself is never locked, wherever it is in the list: the 5th with its own subscription + projectPaid(t.sql, t.apps[4]!.id); + expect(await plansOf(t)).toEqual(["team:paid", "team:paid", "team:paid", "team:paid", "team:paid"]); + t.sql.db.prepare("UPDATE projects SET plan = NULL, subscription_id = NULL, subscription_status = NULL WHERE id = ?").run(t.apps[4]!.id); + // with the account's own subscription (bought under version 5) as well: still 3, Blog not counted paidPro(t.sql, t.account.id, 2); expect((await coverage(t.sql.api, t.account.id, "live", now())).cap).toBe(3); + expect(await plansOf(t)).toEqual(["team:paid", "team:paid", "team:paid", "team:paid", "pro_apps"]); // a cancelled one is gone: the account is on Free again t.sql.db.prepare("UPDATE projects SET plan = NULL, subscription_status = 'canceled' WHERE account_id = ?").run(t.account.id); setPlan(t.sql, t.account.id, "free", { subscription_status: "canceled" }); - expect(await plansOf(t)).toEqual(["free", "free_projects", "free_projects", "free_projects"]); + expect(await plansOf(t)).toEqual(["free", "free_projects", "free_projects", "free_projects", "free_projects"]); }); it("GenClass's grant covers every app: the account's (Enterprise or Pro), or one copied onto an app", async () => { @@ -495,8 +499,17 @@ describe("an app past what the plan covers waits on its plan page (Free: one app const t = await withApps(["Shop", "Blog"]); const one = await text(call(t.env, "GET", `/account/billing?project=${t.apps[0]!.id}&from=cli`, { cookie: t.cookie })); expect(one).toContain("

    Choose a plan for Shop

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

    Pro

    $15 / developer / month

    '); + // a just-added app: one short line under the heading, no notice box and no "0 of 100" (the owner, 2026-11-13: less written) + expect(one).toContain('

    Shop is in your account.

    '); + expect(one).not.toContain("Choose its plan to go on"); + expect(one).not.toContain("Problems fixed live this month"); + // the card: the name, the price, the first 5 lines, the rest folded under "N more"; no summary repeating the price + expect(one).toContain('

    Pro

    $15 / developer / month

    • Up to 3 apps
    • The genclass command in your terminal
    • '); + const lines = pitchLines(PLANS[1]!); + expect(one).toContain(`
      ${lines.length - 5} more
        ${lines.slice(5).map((l) => `
      • ${l.replace(/'/g, "'")}
      • `).join("")}
      `); + expect(one).not.toContain("
    • Everything in Free
    • "); + expect(one).not.toContain("
    • 3 days free to start
    • "); + expect(one).not.toContain('

      '); // an account that has never had a subscription starts with Pro's 3 days free (billing.ts trialFor) expect(one).toContain(''); expect(one).toContain('

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

      '); @@ -514,7 +527,6 @@ describe("an app past what the plan covers waits on its plan page (Free: one app // it never sends anyone back to a terminal: the choice is made here (Pro's card lists the command as a feature) expect(one).toContain("
    • The genclass command in your terminal
    • "); expect(one.replace("
    • The genclass command in your terminal
    • ", "").toLowerCase()).not.toContain("terminal"); - expect(one).toContain("Shop is in your account. Choose its plan to go on."); // ?start=1 (the genclass command) opens the trial's Checkout at once expect(await text(call(t.env, "GET", `/account/billing?project=${t.apps[0]!.id}&start=1`, { cookie: t.cookie }))).toContain('"go":true'); // signed out: through the sign-in, back to the same page @@ -529,7 +541,8 @@ describe("an app past what the plan covers waits on its plan page (Free: one app const t = await withApps(["Shop", "Blog"]); const page = await text(call(t.env, "GET", `/account/billing?project=${t.apps[1]!.id}`, { cookie: t.cookie })); expect(page).toContain("

      Choose a plan for Blog

      "); - expect(page).toContain("Free covers one app, and Blog is one more: it needs Pro."); + // why it is locked: Free covered 3 apps before version 5 (the owner kept Free at one app) + expect(page).toContain("Since 13 November 2026 Free covers one app, your oldest, and Blog is past it: it needs Pro."); expect(page).toContain('data-plan="team" data-pick>Start your 3-day free trial'); expect(page).not.toContain("Try for free"); expect(page).not.toContain('id="why"'); @@ -691,7 +704,10 @@ describe("the website: a new account's first screen is Pro's trial, with a tiny expect(page).toContain('

      Start your 3-day free trial

      '); expect(page).toContain('

      Pro, $15 / developer / month

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

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

      "); + // 5 lines shown, the rest folded, and no summary repeating the price (the owner, 2026-11-13: less written) + expect(page).toContain(`
      ${pitchLines(PLANS[1]!).length - 5} more`); + expect(page).not.toContain("Everything for up to 3 apps, per developer."); expect(page).toContain('

      Skip

      '); expect(page).toContain("
    • Up to 3 apps
    • "); expect(page).not.toContain("
    • Everything in Free
    • "); @@ -806,6 +822,9 @@ describe("problems fixed live: 100 a month per project on Free, then observe onl const st = await json(call(t.env, "GET", `/api/account/projects/${app.id}/stats?range=7d`, { cookie: a.cookie })); expect(st.usage).toEqual({ used: 120, limit: 100, observe_only: true, resets: monthOf(new Date(st.generated)).resets }); expect(st.plan_url).toBe(`https://genclass.dev/account/billing?project=${app.id}`); + // the plan page says so, once there are fixes to count (a new app's "0 of 100" is left out) + const planPage = await text(call(t.env, "GET", `/account/billing?project=${app.id}`, { cookie: a.cookie })); + expect(planPage).toContain("Observing only. Problems fixed live this month: 120 of 100."); // the account on Pro: no limit, no header paidPro(t.sql, a.account.id); resetOwnerCache(); @@ -850,3 +869,115 @@ describe("Pro's 3-day trial (plans.json trial_days), on an account's first subsc expect(later.form!.has("metadata[genclass_trial_days]")).toBe(false); }); }); + +describe("what was bought before version 5 keeps what it covered, and a paying team keeps its members (the 0.3.7 report)", () => { + const plansOf = async (t: { sql: Sql; env: any; account: { id: string } }) => + (await accountProjectsAccess(t.sql.api, t.account.id, t.env, now())).map((x) => x.access.locked ?? `${x.access.plan.id}:${x.access.source}`); + const kept = (sql: Sql, id: string) => (sql.db.prepare("SELECT apps_kept FROM accounts WHERE id = ?").get(id) as { apps_kept: number | null }).apps_kept; + + it("a version-4 subscription of one app holds the whole team: no seat limit, so its members keep the dashboards and the command", async () => { + const t = await withApps(["Shop"]); + projectPaid(t.sql, t.apps[0]!.id); + expect(await accountAccess(t.sql.api, t.account.id, t.env, now())).toMatchObject({ source: "paid", plan: { id: "team" }, seats: null }); + for (const email of ["bob@example.com", "cy@example.com"]) { + const m = await signedIn(t.env, email); + const inv = await json(call(t.env, "POST", "/api/team/invites", { body: {}, cookie: t.cookie })); + expect((await call(t.env, "POST", "/api/team/join", { body: { code: inv.url.split("/").pop() }, cookie: m.cookie })).status).toBe(200); + expect((await call(t.env, "GET", `/account/apps/${t.apps[0]!.id}`, { cookie: m.cookie })).status).toBe(200); + // the genclass command works for the member, under the team's Pro (src/cli.ts cliVia, workingAccess) + const bearer = await cliToken(t.env, m.cookie); + const seen = await json(call(t.env, "GET", "/api/cli/overview", { bearer, origin: null })); + expect(seen.cli).toEqual({ paid: true, via: "team" }); + expect(seen.plan).toEqual({ id: "team", name: "Pro", via: "team" }); + } + }); + + it("an account subscription from before version 5 keeps covering the apps the account had (accounts.apps_kept) while it lasts", async () => { + const b = await buyer(); + for (const n of ["Docs", "Extra", "Fifth"]) await createApp(b.env, b.cookie, n); + // a subscription of the whole account from version 3 (or bought per app), as migration 0021 leaves it + paidPro(b.sql, b.account.id, 1); + b.sql.db.prepare("UPDATE accounts SET apps_kept = 5 WHERE id = ?").run(b.account.id); + expect(await plansOf(b)).toEqual(Array(5).fill("team:paid")); + // an app added since is past them: Pro's 3, or the 5 kept, whichever is more + await createApp(b.env, b.cookie, "Sixth"); + expect(await plansOf(b)).toEqual([...Array(5).fill("team:paid"), "pro_apps"]); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + // the same subscription changes (a seat added in Manage billing): still kept + const more = accountSub("sub_acct000001", b.account.id, "cus_ada0000001", "active", 2); + b.s.subs.set(more.id, more); + expect((await json(deliver(b.env, stripeEvent("customer.subscription.updated", more), b.s.fetch))).result).toBe("applied"); + expect(kept(b.sql, b.account.id)).toBe(5); + // it ends: so do the kept apps, and a later subscription (version 5) covers 3 + const ended = accountSub("sub_acct000001", b.account.id, "cus_ada0000001", "canceled"); + b.s.subs.set(ended.id, ended); + expect((await json(deliver(b.env, stripeEvent("customer.subscription.deleted", ended), b.s.fetch))).result).toBe("applied"); + expect(kept(b.sql, b.account.id)).toBeNull(); + paidPro(b.sql, b.account.id, 1); + expect(await plansOf(b)).toEqual([...Array(3).fill("team:paid"), "pro_apps", "pro_apps", "pro_apps"]); + log.mockRestore(); + }); + + it("migration 0021: a paying or granted account's apps count as chosen, and an account subscription keeps its apps; Free's do not", async () => { + const t = setup(LIVE); + const ada = await signedIn(t.env); + const bob = await signedIn(t.env, "bob@example.com"); + const cy = await signedIn(t.env, "cy@example.com"); + const adaApps = [await createApp(t.env, ada.cookie, "Shop"), await createApp(t.env, ada.cookie, "Blog")]; + const bobApps = [await createApp(t.env, bob.cookie, "Mine"), await createApp(t.env, bob.cookie, "Other")]; + const cyApps = [await createApp(t.env, cy.cookie, "Paid"), await createApp(t.env, cy.cookie, "Free one")]; + paidPro(t.sql, ada.account.id, 1); + projectPaid(t.sql, cyApps[0]!.id); + // the migration's own statements, run again on these rows (the tests' database ran it empty) + const sqlText = readFileSync(new URL("../migrations/0021_plans_v5_kept.sql", import.meta.url), "utf8").split("\n").filter((l) => !l.startsWith("--")).join("\n"); + t.sql.db.exec(sqlText.replace(/ALTER TABLE accounts ADD COLUMN apps_kept INTEGER;/, "")); + expect(kept(t.sql, ada.account.id)).toBe(2); + expect(kept(t.sql, bob.account.id)).toBeNull(); + expect(kept(t.sql, cy.account.id)).toBeNull(); + for (const a of [...adaApps, ...cyApps]) expect(chosenAt(t.sql, a.id), a.name).not.toBeNull(); + for (const a of bobApps) expect(chosenAt(t.sql, a.id), a.name).toBeNull(); + // so an app Pro covered before opens its dashboard as it did, not "Choose a plan" + const page = await call(t.env, "GET", `/account/apps/${adaApps[1]!.id}`, { cookie: ada.cookie }); + expect(page.status).toBe(200); + }); + + it("a paid Pro under GenClass's grant of Pro keeps the grant's room: every app, no seat limit", async () => { + const t = await withApps(["Shop", "Blog", "Docs", "Extra", "Fifth"]); + setPlan(t.sql, t.account.id, "free", { comp_until: "9999-12-31T00:00:00.000Z", comp_plan: "team" }); + paidPro(t.sql, t.account.id, 1); + expect(await plansOf(t)).toEqual(Array(5).fill("team:paid")); + expect(await accountAccess(t.sql.api, t.account.id, t.env, now())).toMatchObject({ source: "paid", seats: null }); + }); + + it("from the genclass command, a plan choice goes on to the app's dashboard, never the website's setup with other modes ticked", async () => { + const t = await withApps(["Shop"]); + const page = await text(call(t.env, "GET", `/account/billing?project=${t.apps[0]!.id}&from=cli`, { cookie: t.cookie })); + expect(page.split('(cfg.isNew && !cfg.cli ? "#connect" : "")').length).toBe(3); + expect(page).not.toContain("cfg.isNew || cfg.cli"); + }); + + it("the command's offer lists the trial's line only while the account still has the trial", async () => { + const t = await withApps(["Shop"]); + const bearer = await cliToken(t.env, t.cookie); + const offer = async () => (await json(call(t.env, "GET", "/api/cli/overview", { bearer, origin: null }))).offer; + expect((await offer()).trial_days).toBe(3); + expect((await offer()).features).toContain("3 days free to start"); + setPlan(t.sql, t.account.id, "free", { subscription_id: "sub_old0000001", subscription_status: "canceled" }); + expect((await offer()).trial_days).toBeNull(); + expect((await offer()).features).not.toContain("3 days free to start"); + expect((await offer()).features).toContain("Up to 3 apps"); + }); +}); + +describe("sign-in pages the genclass command opens (the 0.3.7 walk)", () => { + it("the approval never says to go back to the terminal, and a command-line sign-in has no side door to an account-less dashboard", async () => { + const t = setup(LIVE); + const a = await signedIn(t.env); + const device = await text(call(t.env, "GET", "/login/device", { cookie: a.cookie })); + expect(device).toContain('finish("Approved", "Your terminal is signed in. You can close this tab.")'); + expect(device.toLowerCase()).not.toContain("back to your terminal"); + const fromCli = await text(call(t.env, "GET", "/signup?next=%2Flogin%2Fdevice%3Fcode%3DBCDF-GHJK", {})); + expect(fromCli).not.toContain("Just trying it?"); + expect(await text(call(t.env, "GET", "/signup", {}))).toContain("Just trying it?"); + }); +}); diff --git a/telemetry-worker/test/team.test.ts b/telemetry-worker/test/team.test.ts index c24d2d27..6b1cb0e2 100644 --- a/telemetry-worker/test/team.test.ts +++ b/telemetry-worker/test/team.test.ts @@ -73,11 +73,12 @@ describe("invite links: who can make one, and how many", () => { expect((await call(env, "DELETE", `/api/team/invites/${a.id}`, { cookie })).status).toBe(204); expect((await call(env, "DELETE", `/api/team/invites/${a.id}`, { cookie })).status).toBe(404); await invite(env, cookie); - // a Team with one seat has no place for a member either, and the message says what to do + // Pro with one seat (the owner's) has no place for a member either, and the message says where seats are added: + // Manage billing, never "Pro is for one person" (the 0.3.7 report) team(sql, account.id, 1); const one = await call(env, "POST", "/api/team/invites", { body: {}, cookie }); - expect(one.status).toBe(403); - expect((await one.json()).upgrade_url).toBe(PLANS_URL); + expect(one.status).toBe(409); + expect((await one.json()).error).toBe("your Team plan has one seat, yours: add a seat for each member in Manage billing ($15 / developer / month)"); }); it("only the browser session with our Origin: a CLI session and a cross-origin post are refused, a signed-out one is 401", async () => { @@ -150,7 +151,7 @@ describe("joining: the invite page, single use, 7 days", () => { team(sql, owner.account.id, 1); const r = await join(env, bob.cookie, late.code); expect(r.status).toBe(409); - expect((await r.json()).error).toBe("this team's plan has no seat for members now; ask its owner"); + expect((await r.json()).error).toBe("this team's plan has one seat, its owner's: ask its owner to add one for you in Manage billing"); expect(sql.rows("SELECT member_id FROM team_members")).toEqual([]); }); });